mirror of
https://github.com/metabarcoding/obitools4.git
synced 2026-11-09 09:24:41 +00:00
Feature: Implement constrained k-mer generation and clique search
This commit introduces core functionality for oligonucleotide family tagging, constrained k-mer generation, and clique finding. It also includes updates to the Go language version and refactors the logic for k-mer dissimilarity metrics.
This commit is contained in:
1 parent
f76e7f03c6
commit
3087c70daa
17 files changed
+1791
-13
No files matched your search
@@ -0,0 +1,176 @@
|
||||
package obigraph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// NeighborFilter defines an implicit graph over the vertices 0..n-1.
|
||||
// It appends to out[:0] the vertices of candidates that are adjacent to v,
|
||||
// keeping their order, and returns the resulting slice. It is called
|
||||
// concurrently by several workers and must not modify candidates.
|
||||
//
|
||||
// Describing the adjacency by a filter, instead of a matrix, keeps the memory
|
||||
// in O(n) and lets the caller evaluate adjacency in bulk (SIMD, for instance).
|
||||
type NeighborFilter func(v int32, candidates, out []int32) []int32
|
||||
|
||||
// CliqueOptions tunes FindClique.
|
||||
type CliqueOptions struct {
|
||||
// Workers is the number of concurrent searches (default 1).
|
||||
Workers int
|
||||
// Seed seeds the random vertex orders of the restarts.
|
||||
Seed int64
|
||||
}
|
||||
|
||||
// CliqueResult is the outcome of FindClique.
|
||||
type CliqueResult struct {
|
||||
// Clique is the largest clique found (a clique of size >= target if Reached).
|
||||
Clique []int32
|
||||
// Reached is true if len(Clique) >= target.
|
||||
Reached bool
|
||||
// Exhausted is true if the whole search space has been explored. If Reached
|
||||
// is false, it proves that no clique of the target size exists.
|
||||
Exhausted bool
|
||||
}
|
||||
|
||||
// FindClique looks for a clique of at least target vertices in the graph of n
|
||||
// vertices described by filter. It stops as soon as one is found, when the
|
||||
// search space is exhausted, or when ctx is done; in the last case, the largest
|
||||
// clique met so far is returned.
|
||||
//
|
||||
// The search is a depth first search with backtracking, pruned when the
|
||||
// current clique plus the remaining candidates cannot reach target. The first
|
||||
// pass of the first worker follows the natural order of the vertices (greedy
|
||||
// "lexicographic" construction); every other pass restarts from a random
|
||||
// order, with a work budget that doubles at each restart, so that the search
|
||||
// is complete in the long run.
|
||||
func FindClique(ctx context.Context, n int, filter NeighborFilter, target int, opts CliqueOptions) CliqueResult {
|
||||
if target <= 0 {
|
||||
return CliqueResult{Reached: true, Exhausted: true}
|
||||
}
|
||||
if n <= 0 {
|
||||
return CliqueResult{Exhausted: true}
|
||||
}
|
||||
|
||||
workers := max(opts.Workers, 1)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
mutex sync.Mutex
|
||||
best []int32
|
||||
exhausted bool
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
publish := func(clique []int32) {
|
||||
mutex.Lock()
|
||||
if len(clique) > len(best) {
|
||||
best = append(best[:0], clique...)
|
||||
}
|
||||
mutex.Unlock()
|
||||
}
|
||||
|
||||
for w := range workers {
|
||||
wg.Go(func() {
|
||||
s := &cliqueSearch{
|
||||
ctx: ctx,
|
||||
filter: filter,
|
||||
target: target,
|
||||
publish: func(c []int32) {
|
||||
publish(c)
|
||||
if len(c) >= target {
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
}
|
||||
rng := rand.New(rand.NewSource(opts.Seed + int64(w)))
|
||||
candidates := make([]int32, n)
|
||||
for i := range candidates {
|
||||
candidates[i] = int32(i)
|
||||
}
|
||||
|
||||
for restart := 0; ctx.Err() == nil; restart++ {
|
||||
if w > 0 || restart > 0 {
|
||||
rng.Shuffle(n, func(i, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] })
|
||||
}
|
||||
s.budget = int64(n) * int64(target) << min(restart, 20)
|
||||
s.work = 0
|
||||
s.aborted = false
|
||||
s.search(0, candidates)
|
||||
|
||||
if !s.aborted && ctx.Err() == nil {
|
||||
mutex.Lock()
|
||||
exhausted = true
|
||||
mutex.Unlock()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return CliqueResult{
|
||||
Clique: best,
|
||||
Reached: len(best) >= target,
|
||||
Exhausted: exhausted,
|
||||
}
|
||||
}
|
||||
|
||||
type cliqueSearch struct {
|
||||
ctx context.Context
|
||||
filter NeighborFilter
|
||||
target int
|
||||
publish func([]int32)
|
||||
|
||||
current []int32 // the clique under construction
|
||||
buffers [][]int32 // candidate list of each depth
|
||||
bestLen int
|
||||
|
||||
budget int64 // maximum work (number of candidates tested) for this restart
|
||||
work int64
|
||||
aborted bool // budget or context exhausted before the end of the exploration
|
||||
}
|
||||
|
||||
func (s *cliqueSearch) search(depth int, candidates []int32) {
|
||||
for i := range candidates {
|
||||
if depth+len(candidates)-i < s.target {
|
||||
return
|
||||
}
|
||||
if s.work > s.budget || s.ctx.Err() != nil {
|
||||
s.aborted = true
|
||||
return
|
||||
}
|
||||
|
||||
v := candidates[i]
|
||||
s.current = append(s.current[:depth], v)
|
||||
if len(s.current) > s.bestLen {
|
||||
s.bestLen = len(s.current)
|
||||
s.publish(s.current)
|
||||
if s.bestLen >= s.target {
|
||||
s.aborted = true // the search is over: do not report an exhaustion
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rest := candidates[i+1:]
|
||||
if depth+1+len(rest) < s.target {
|
||||
continue
|
||||
}
|
||||
for len(s.buffers) <= depth+1 {
|
||||
s.buffers = append(s.buffers, nil)
|
||||
}
|
||||
next := s.filter(v, rest, s.buffers[depth+1][:0])
|
||||
s.buffers[depth+1] = next
|
||||
s.work += int64(len(rest))
|
||||
|
||||
if depth+1+len(next) < s.target {
|
||||
continue
|
||||
}
|
||||
s.search(depth+1, next)
|
||||
if s.aborted {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package obigraph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/bits"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// adjacency matrix filter for small graphs
|
||||
func matrixFilter(adj [][]bool) NeighborFilter {
|
||||
return func(v int32, candidates, out []int32) []int32 {
|
||||
for _, c := range candidates {
|
||||
if adj[v][c] {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func randomGraph(rng *rand.Rand, n int, p float64) [][]bool {
|
||||
adj := make([][]bool, n)
|
||||
for i := range adj {
|
||||
adj[i] = make([]bool, n)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
for j := i + 1; j < n; j++ {
|
||||
if rng.Float64() < p {
|
||||
adj[i][j], adj[j][i] = true, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return adj
|
||||
}
|
||||
|
||||
// maximum clique size by brute force over all the subsets (n <= 20)
|
||||
func bruteMaxClique(adj [][]bool) int {
|
||||
n := len(adj)
|
||||
best := 0
|
||||
for mask := 1; mask < 1<<n; mask++ {
|
||||
size := bits.OnesCount(uint(mask))
|
||||
if size <= best {
|
||||
continue
|
||||
}
|
||||
ok := true
|
||||
for i := 0; i < n && ok; i++ {
|
||||
if mask>>i&1 == 0 {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < n; j++ {
|
||||
if mask>>j&1 == 1 && !adj[i][j] {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
best = size
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func isClique(adj [][]bool, c []int32) bool {
|
||||
seen := map[int32]bool{}
|
||||
for i, a := range c {
|
||||
if seen[a] {
|
||||
return false
|
||||
}
|
||||
seen[a] = true
|
||||
for _, b := range c[i+1:] {
|
||||
if !adj[a][b] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestFindCliqueAgainstBruteForce(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
for trial := 0; trial < 40; trial++ {
|
||||
n := 8 + rng.Intn(10)
|
||||
adj := randomGraph(rng, n, 0.3+0.5*rng.Float64())
|
||||
maxSize := bruteMaxClique(adj)
|
||||
filter := matrixFilter(adj)
|
||||
|
||||
for _, workers := range []int{1, 3} {
|
||||
r := FindClique(context.Background(), n, filter, maxSize, CliqueOptions{Workers: workers})
|
||||
if !r.Reached || len(r.Clique) < maxSize || !isClique(adj, r.Clique) {
|
||||
t.Fatalf("trial %d: target %d not reached: %+v", trial, maxSize, r)
|
||||
}
|
||||
|
||||
r = FindClique(context.Background(), n, filter, maxSize+1, CliqueOptions{Workers: workers})
|
||||
if r.Reached || !r.Exhausted {
|
||||
t.Fatalf("trial %d: size %d cannot exist, got %+v", trial, maxSize+1, r)
|
||||
}
|
||||
// best effort: a valid clique, not necessarily a maximum one
|
||||
if len(r.Clique) < 1 || len(r.Clique) > maxSize || !isClique(adj, r.Clique) {
|
||||
t.Fatalf("trial %d: invalid best clique %v (maximum is %d)", trial, r.Clique, maxSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCliqueTrivialTargets(t *testing.T) {
|
||||
adj := randomGraph(rand.New(rand.NewSource(2)), 5, 0.5)
|
||||
if r := FindClique(context.Background(), 5, matrixFilter(adj), 0, CliqueOptions{}); !r.Reached {
|
||||
t.Fatal("target 0 is always reached")
|
||||
}
|
||||
if r := FindClique(context.Background(), 0, matrixFilter(adj), 1, CliqueOptions{}); r.Reached {
|
||||
t.Fatal("no clique in an empty graph")
|
||||
}
|
||||
if r := FindClique(context.Background(), 5, matrixFilter(adj), 1, CliqueOptions{}); !r.Reached || len(r.Clique) != 1 {
|
||||
t.Fatalf("a single vertex is a clique: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// A hopeless search must stop with the deadline and return a valid clique.
|
||||
// (When the target is far above the clique number of a sparse graph, pruning
|
||||
// proves the absence at once: see TestFindCliqueAgainstBruteForce.)
|
||||
func TestFindCliqueTimeout(t *testing.T) {
|
||||
// dense graph: its largest clique is far below the target, but pruning only
|
||||
// bites after many levels, so the search cannot finish within the deadline
|
||||
const n = 300
|
||||
adj := randomGraph(rand.New(rand.NewSource(3)), n, 0.9)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
r := FindClique(ctx, n, matrixFilter(adj), 150, CliqueOptions{Workers: 2})
|
||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||
t.Fatalf("deadline not honoured: %v", elapsed)
|
||||
}
|
||||
if r.Reached || r.Exhausted {
|
||||
t.Fatalf("size 150 cannot be reached nor proven absent in time: %+v", r)
|
||||
}
|
||||
if len(r.Clique) < 2 || !isClique(adj, r.Clique) {
|
||||
t.Fatalf("invalid best clique %v", r.Clique)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user