mirror of
https://github.com/metabarcoding/obitools4.git
synced 2026-11-09 17:24:41 +00:00
Corrects the indexing strategy for seed searching, improving performance and introducing a new seed index structure for efficient pattern lookup. This commit updates the core matching logic by shifting the indexing strategy to use two halves of k/2 bases, resulting in significant speed improvements for sequence matching. It also introduces a new `seedIndex` structure for fast pattern retrieval and implements comprehensive performance testing logic for matching functions. Future features, such as bit-parallel search, are deferred.
305 lines
9.6 KiB
Go
305 lines
9.6 KiB
Go
package obiprimer
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"math/rand"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obidefault"
|
|
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obiformats"
|
|
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obikmer"
|
|
"git.metabarcoding.org/obitools/obitools4/obitools4/pkg/obiseq"
|
|
)
|
|
|
|
func corpusOf(roles []Role, seqs ...string) *Corpus {
|
|
c := &Corpus{Roles: roles}
|
|
for i, s := range seqs {
|
|
c.Seqs = append(c.Seqs, obiseq.NewBioSequence(fmt.Sprintf("s%d", i), []byte(s), ""))
|
|
}
|
|
return c
|
|
}
|
|
|
|
func canon(word string) uint64 { return obikmer.EncodeCanonicalKmer([]byte(word), len(word)) }
|
|
|
|
// The reference implementation is checked by hand first: everything else is
|
|
// compared with it.
|
|
func TestNaiveMatchSemantics(t *testing.T) {
|
|
const word = "AACCGGTA" // not a palindrome; reverse complement TACCGGTT
|
|
cases := []struct {
|
|
name string
|
|
seq string
|
|
circular bool
|
|
e int
|
|
want []Match
|
|
}{
|
|
{"exact forward", "ggaaccggtacc", false, 0, []Match{{0, 2, 0, false}}},
|
|
{"exact reverse strand", "ggtaccggttcc", false, 0, []Match{{0, 2, 0, true}}},
|
|
{"one error is not found at e=0", "ggaaccgctacc", false, 0, nil},
|
|
{"one error found at e=1 with its count", "ggaaccgctacc", false, 1, []Match{{0, 2, 1, false}}},
|
|
{"an N is an error", "ggaaccgntacc", false, 1, []Match{{0, 2, 1, false}}},
|
|
{"an N is never a match at e=0", "ggaaccgntacc", false, 0, nil},
|
|
{"an IUPAC code is an error, not a wildcard", "ggaaccgrtacc", false, 0, nil},
|
|
{"U reads as T", "ggaaccgguacc", false, 0, []Match{{0, 2, 0, false}}},
|
|
{"junction ignored on a linear sequence", "ccggtagggaa", false, 0, nil},
|
|
{"junction found on a circular sequence", "ccggtagggaa", true, 0, []Match{{0, 9, 0, false}}},
|
|
{"overlapping windows are all reported", "aaccggtaaccggta", false, 0, []Match{{0, 0, 0, false}, {0, 7, 0, false}}},
|
|
{"too short", "aaccgg", true, 3, nil},
|
|
}
|
|
for _, tc := range cases {
|
|
c := corpusOf([]Role{Example}, tc.seq)
|
|
got, err := matchNaive(c, []uint64{canon(word)}, MatchParams{K: len(word), MaxErrors: tc.e, Circular: tc.circular})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(got[0].Matches, tc.want) {
|
|
t.Errorf("%s: got %v, want %v", tc.name, got[0].Matches, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchParamsValidation(t *testing.T) {
|
|
c := corpusOf([]Role{Example}, "acgtacgtacgtacgt")
|
|
for _, p := range []MatchParams{{K: 1}, {K: 32}, {K: 8, MaxErrors: -1}, {K: 8, MaxErrors: 8}} {
|
|
if _, err := matchNaive(c, nil, p); err == nil {
|
|
t.Errorf("%+v must be rejected", p)
|
|
}
|
|
if _, err := MatchWords(c, nil, p); err == nil {
|
|
t.Errorf("%+v must be rejected by MatchWords", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func randSeq(rng *rand.Rand, n int, alphabet string) []byte {
|
|
b := make([]byte, n)
|
|
for i := range b {
|
|
b[i] = alphabet[rng.Intn(len(alphabet))]
|
|
}
|
|
return b
|
|
}
|
|
|
|
func mutate(rng *rand.Rand, s []byte, n int) []byte {
|
|
out := append([]byte(nil), s...)
|
|
for _, i := range rng.Perm(len(out))[:n] {
|
|
out[i] = "acgt"[rng.Intn(4)]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// randomCorpus builds sequences with words planted on both strands, with 0..3
|
|
// substitutions, across the circular junction, and with ambiguity codes.
|
|
func randomCorpus(rng *rand.Rand, words []string, nseq int) *Corpus {
|
|
c := &Corpus{}
|
|
for i := 0; i < nseq; i++ {
|
|
alphabet := "acgt"
|
|
if i%3 == 0 {
|
|
alphabet = "acgtacgtacgtacgtacgtnnrykm"
|
|
}
|
|
s := randSeq(rng, 30+rng.Intn(500), alphabet)
|
|
for range 1 + rng.Intn(4) {
|
|
w := []byte(words[rng.Intn(len(words))])
|
|
if rng.Intn(2) == 0 {
|
|
w = []byte(revcomp(string(w)))
|
|
}
|
|
w = mutate(rng, []byte(string(w)), rng.Intn(4))
|
|
for j := range w {
|
|
w[j] += 'a' - 'A'
|
|
}
|
|
at := rng.Intn(len(s) - len(w) + 1)
|
|
if rng.Intn(5) == 0 { // across the junction
|
|
s = append(s[:0:0], append(append([]byte(nil), w[len(w)/2:]...), append(s[:len(s)-len(w)/2], w[:len(w)/2]...)...)...)
|
|
continue
|
|
}
|
|
copy(s[at:], w)
|
|
}
|
|
c.Seqs = append(c.Seqs, obiseq.NewBioSequence(fmt.Sprintf("r%d", i), s, ""))
|
|
c.Roles = append(c.Roles, Example)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Every implementation must return exactly what the reference returns.
|
|
func TestMatchersAgreeWithReference(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
k, e int
|
|
circular bool
|
|
}{{18, 0, false}, {18, 1, true}, {18, 3, false}, {18, 3, true}, {12, 2, true}, {25, 4, false}, {18, 2, true}, {18, 5, false}, {31, 3, true}, {24, 3, false}, {9, 2, true}, {18, 6, true}, {18, 7, false}, {20, 8, true}, {12, 11, false}} {
|
|
t.Run(fmt.Sprintf("k%d-e%d-circ%v", tc.k, tc.e, tc.circular), func(t *testing.T) {
|
|
rng := rand.New(rand.NewSource(int64(tc.k*100 + tc.e)))
|
|
var wordStr []string
|
|
var words []uint64
|
|
for range 7 {
|
|
w := string(randSeq(rng, tc.k, "ACGT"))
|
|
wordStr = append(wordStr, w)
|
|
words = append(words, canon(w))
|
|
}
|
|
c := randomCorpus(rng, wordStr, 40)
|
|
p := MatchParams{K: tc.k, MaxErrors: tc.e, Circular: tc.circular}
|
|
|
|
want, err := matchNaive(c, words, p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
total := 0
|
|
for _, w := range want {
|
|
total += len(w.Matches)
|
|
}
|
|
if total < 10 {
|
|
t.Fatalf("test data too poor: only %d reference matches", total)
|
|
}
|
|
|
|
got, err := MatchWords(c, words, p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := range want {
|
|
if !reflect.DeepEqual(got[i].Matches, want[i].Matches) {
|
|
t.Fatalf("word %d: MatchWords gives %v\nreference gives %v", i, got[i].Matches, want[i].Matches)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSequencesMatched(t *testing.T) {
|
|
c := corpusOf([]Role{Example, Example, CounterExample, CounterExample}, "a", "a", "a", "a")
|
|
w := WordMatches{Matches: []Match{{0, 1, 0, false}, {0, 9, 0, true}, {1, 3, 0, false}, {3, 0, 0, false}}}
|
|
if e, x := w.SequencesMatched(c); e != 2 || x != 1 {
|
|
t.Errorf("got %d examples, %d counterexamples", e, x)
|
|
}
|
|
}
|
|
|
|
func readEcoCounts(t *testing.T, path string) map[string]int {
|
|
t.Helper()
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
counts := map[string]int{}
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
var w string
|
|
var in, out int
|
|
if _, err := fmt.Sscanf(sc.Text(), "%s\t%d\t%d", &w, &in, &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
counts[w] = in
|
|
}
|
|
return counts
|
|
}
|
|
|
|
// Opt-in comparison with ecoPrimers on the tutorial data (same variable as the
|
|
// strict word test). For every strict word, ecoPrimers logs the number of
|
|
// example sequences in which the fuzzy search (-e 3, both strands, circular)
|
|
// finds it; testdata/ecoprimers_*.words.tsv holds those numbers.
|
|
func TestMatchWordsAgainstEcoPrimers(t *testing.T) {
|
|
dir := os.Getenv("OBIPRIMERS_TUTORIAL")
|
|
if dir == "" {
|
|
t.Skip("OBIPRIMERS_TUTORIAL not set")
|
|
}
|
|
if v := os.Getenv("OBIPRIMERS_WORKERS"); v != "" { // e.g. 1, to time a single core
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
obidefault.SetMaxCPU(n)
|
|
}
|
|
taxonomy, err := obiformats.LoadTaxonomy(filepath.Join(dir, "ncbitaxo_20250211.tgz"), false, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, tc := range []struct{ name, taxid string }{{"Cyprinidae", "7953"}, {"Teleostei", "32443"}} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
restrict, err := BelongingTo(taxonomy, []string{tc.taxid})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seqs, err := obiformats.ReadSequencesFromFile(filepath.Join(dir, "mito.vert.fasta.gz"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
corpus, err := LoadCorpus(seqs, Selection{Restrict: restrict})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sw, err := FindStrictWords(corpus, WordSearch{PrimerLength: 18, Quorum: 0.7, Circular: true, TmpDir: t.TempDir()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
words := make([]uint64, len(sw.Words))
|
|
for i, w := range sw.Words {
|
|
words[i] = w.Kmer
|
|
}
|
|
|
|
var before, after runtime.MemStats
|
|
runtime.GC()
|
|
runtime.ReadMemStats(&before)
|
|
start := time.Now()
|
|
res, err := MatchWords(corpus, words, MatchParams{K: 18, MaxErrors: 3, Circular: true})
|
|
elapsed := time.Since(start)
|
|
runtime.ReadMemStats(&after)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nm := 0
|
|
for _, r := range res {
|
|
nm += len(r.Matches)
|
|
}
|
|
bases := 0
|
|
for _, s := range corpus.Seqs {
|
|
bases += s.Len()
|
|
}
|
|
t.Logf("%d words x %d sequences (%.1f Mb) e=3 in %s; %d matches; allocated %.0f MB",
|
|
len(words), len(corpus.Seqs), float64(bases)/1e6, elapsed.Round(time.Millisecond), nm,
|
|
float64(after.TotalAlloc-before.TotalAlloc)/1e6)
|
|
|
|
// On real sequences (with their ambiguity codes), the first ones must give
|
|
// exactly what the reference implementation gives.
|
|
sub := &Corpus{Seqs: corpus.Seqs[:40], Roles: corpus.Roles[:40]}
|
|
wantSub, err := matchNaive(sub, words, MatchParams{K: 18, MaxErrors: 3, Circular: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gotSub, err := MatchWords(sub, words, MatchParams{K: 18, MaxErrors: 3, Circular: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
subMatches := 0
|
|
for i := range wantSub {
|
|
subMatches += len(wantSub[i].Matches)
|
|
if !reflect.DeepEqual(gotSub[i].Matches, wantSub[i].Matches) {
|
|
t.Fatalf("word %d differs from the reference on the first 40 sequences", i)
|
|
}
|
|
}
|
|
t.Logf("first 40 sequences identical to the reference (%d matches)", subMatches)
|
|
|
|
eco := readEcoCounts(t, filepath.Join("testdata", "ecoprimers_"+tc.name+".words.tsv"))
|
|
compared, differ := 0, 0
|
|
for i, r := range res {
|
|
w := strings.ToUpper(string(obikmer.DecodeKmer(words[i], 18, nil)))
|
|
want, ok := eco[w]
|
|
if !ok {
|
|
t.Errorf("word %s is not in the ecoPrimers log", w)
|
|
continue
|
|
}
|
|
got, _ := r.SequencesMatched(corpus)
|
|
compared++
|
|
if got != want {
|
|
differ++
|
|
t.Errorf("word %s: %d example sequences, ecoPrimers %d", w, got, want)
|
|
}
|
|
}
|
|
t.Logf("%d words compared with ecoPrimers, %d differ", compared, differ)
|
|
})
|
|
}
|
|
}
|