Skip to content

Commit 6156664

Browse files
authored
Add multi-hash support for common Bloom filter (#88)
* Add multi-hash to ntsynt_make_common_bf.cpp * Add hashes option to python driver and snakemake files * Update tests for multi-hash * Update CI * Update README.md * Update btllib version in README.md * Update default hash number in ntsynt_make_common_bf.cpp
1 parent 5f7b543 commit 6156664

16 files changed

Lines changed: 418556 additions & 417680 deletions

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Lauren Coombe, Parham Kazemi, Johnathan Wong, Inanc Birol, René L. Warren. 2025
4141
## Usage
4242

4343
```
44-
usage: ntSynt [-h] [--fastas_list FASTAS_LIST] -d DIVERGENCE [-p PREFIX] [-k K] [-w W] [-t T] [--fpr FPR] [-b BLOCK_SIZE] [--merge MERGE]
44+
usage: ntSynt [-h] [--fastas_list FASTAS_LIST] -d DIVERGENCE [-p PREFIX] [-k K] [-w W] [-t T] [--fpr FPR] [--hashes HASHES] [-b BLOCK_SIZE] [--merge MERGE]
4545
[--w_rounds W_ROUNDS [W_ROUNDS ...]] [--indel INDEL] [-n] [--benchmark] [-f] [--dev] [-v]
4646
[fastas ...]
4747
@@ -64,6 +64,7 @@ optional arguments:
6464
-w W Minimizer window size [1000]
6565
-t T Number of threads [12]
6666
--fpr FPR False positive rate for Bloom filter creation [0.025]
67+
--hashes HASHES Number of hash functions for Bloom filter creation [3]
6768
-b BLOCK_SIZE, --block_size BLOCK_SIZE
6869
Minimum synteny block size (bp)
6970
--merge MERGE Maximum distance between collinear synteny blocks for merging (bp).
@@ -108,7 +109,7 @@ conda install -c bioconda -c conda-forge ntsynt
108109
- [ncls](https://github.com/pyranges/ncls)
109110
- [python-igraph](https://python.igraph.org/en/stable/)
110111
- GCC 6+ or Clang 5+ (with OpenMP and C++17 support)
111-
- [btllib v1.6.2+](https://github.com/BirolLab/btllib)
112+
- [btllib v1.7.8+](https://github.com/BirolLab/btllib)
112113
- [meson](https://mesonbuild.com/)
113114
- [ninja](https://ninja-build.org/)
114115
- [snakemake](https://snakemake.readthedocs.io/en/stable/)
@@ -173,7 +174,7 @@ optional arguments:
173174
More information can be found on our [wiki page](https://github.com/BirolLab/ntSynt/wiki/de-novo-statistics-summary)
174175

175176
### Tips / Visualization <a name=tips></a>
176-
- To lower the peak memory usage, increase the false positive rate (--fpr) for the constructed Bloom filter
177+
- To lower the peak memory usage, increase the number of hash functions (--hashes) and/or increase the false positive rate (--fpr) for the constructed Bloom filter
177178
- Customize parameters such as --merge, --indel, --block_size and --w_rounds for your particular input data and research questions
178179
- For visualizing the multi-genome output synteny blocks, check out our streamlined visualization tool [ntSynt-viz](https://github.com/BirolLab/ntSynt-viz). Examples of the scripts used to generate the figures in the [ntSynt manuscript](https://link.springer.com/article/10.1186/s12915-025-02455-w) can be found in the sub-directory [visualization_scripts](https://github.com/BirolLab/ntSynt/tree/main/visualization_scripts)
179180
- If you do not know the approximate sequence divergence between the input assemblies, we recommend using [Mash](https://github.com/marbl/Mash) to estimate the divergences

azure-pipelines.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
- script: |
3232
source activate ntsynt_ci
3333
mamba install --yes -c bioconda -c conda-forge python=3.12
34-
mamba install --yes -c bioconda -c conda-forge intervaltree pybedtools ncls 'python-igraph>=1.0.0' compilers btllib meson ninja snakemake samtools pytest seqtk pylint
34+
MAMBA_USE_SHARDED_REPODATA=OFF mamba install --yes -c bioconda -c conda-forge intervaltree pybedtools ncls 'python-igraph>=1.0.0' compilers btllib meson ninja snakemake samtools pytest seqtk pylint
3535
displayName: Install dependencies
3636
3737
- script: |
@@ -81,7 +81,8 @@ jobs:
8181

8282
- script: |
8383
mkdir -p ~/miniforge3
84-
curl -L https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-MacOSX-x86_64.sh -o ~/miniforge3/miniforge.sh
84+
# May 2026: latest miniforge uses a mamba version which is much slower. Pin to lower version for now.
85+
curl -L https://github.com/conda-forge/miniforge/releases/download/26.1.0-0/Miniforge3-MacOSX-x86_64.sh -o ~/miniforge3/miniforge.sh
8586
bash ~/miniforge3/miniforge.sh -b -u -p ~/miniforge3
8687
rm -rf ~/miniforge3/miniforge.sh
8788
~/miniforge3/bin/conda init bash
@@ -95,7 +96,8 @@ jobs:
9596

9697
- script: |
9798
source activate ntsynt_ci
98-
mamba install --yes -c conda-forge -c bioconda intervaltree pybedtools ncls python-igraph compilers btllib meson ninja snakemake samtools pytest seqtk pigz
99+
mamba --version
100+
mamba install --yes -c bioconda -c conda-forge intervaltree pybedtools ncls python-igraph compilers 'btllib>=1.7.8' meson ninja snakemake samtools pytest seqtk pigz
99101
displayName: Install dependencies
100102
101103
- script: |

bin/ntSynt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import argparse
77
import os
88
import shlex
99
import subprocess
10+
import re
1011
from packaging import version
1112

1213
import snakemake
@@ -30,6 +31,15 @@ def read_fasta_files(filename):
3031
fastas.append(fasta.strip())
3132
return fastas
3233

34+
def get_btllib_version():
35+
"Return the version of btllib (By checking indexlr) installed"
36+
result = subprocess.run(["indexlr", "--version"], text=True, check=True,
37+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
38+
match = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
39+
if not match:
40+
raise ValueError(f"Could not parse version from: {result.stdout!r}")
41+
return version.Version(match.group(1))
42+
3343
def main():
3444
"Run ntSynt snakemake file"
3545

@@ -59,6 +69,8 @@ def main():
5969
parser.add_argument("-t", help="Number of threads [12]", type=int, default=12)
6070
parser.add_argument("--fpr", help="False positive rate for Bloom filter creation [0.025]",
6171
default=0.025, type=float)
72+
parser.add_argument("--hashes", help="Number of hash functions for Bloom filter creation [3]", default=3,
73+
type=int)
6274
parser.add_argument("-b", "--block_size", help="Minimum synteny block size (bp)",
6375
type=int, required=False)
6476
parser.add_argument("--merge", help="Maximum distance between collinear synteny blocks for merging (bp). \n"\
@@ -110,6 +122,14 @@ def main():
110122
if args.fastas and args.fastas_list:
111123
parser.error("Please supply the input genome fasta files as positional arguments, "\
112124
"or specify a single file (one fasta per line) with --fastas_list, NOT both.")
125+
126+
if args.hashes > 1:
127+
btllib_version = get_btllib_version()
128+
if btllib_version < version.Version("1.7.8"):
129+
print("WARNING: Using > 1 hash function for Bloom filter creation requires btllib 1.7.8 or higher. " \
130+
f"Current version: {btllib_version}. Please update btllib to use this feature.", flush=True)
131+
print("WARNING: Setting --hashes 1 to proceed with ntSynt run.", flush=True)
132+
args.hashes = 1
113133

114134
if args.fastas_list:
115135
fastas_input = read_fasta_files(args.fastas_list)
@@ -134,6 +154,7 @@ def main():
134154
f"\t-w {args.w}",
135155
f"\t-t {args.t}",
136156
f"\t--fpr {args.fpr}",
157+
f"\t--hashes {args.hashes}",
137158
]
138159
print("\n".join(intro_string), flush=True)
139160

@@ -146,7 +167,7 @@ def main():
146167
command = f"snakemake -s {base_dir}/ntsynt_run_pipeline.smk -p --cores {args.t} " \
147168
f"--config references='{fastas_input}' kmer={args.k} window={args.w} threads={args.t} fpr={args.fpr} " \
148169
f"prefix={args.prefix} w_rounds='{args.w_rounds}' indel_merge={args.indel} " \
149-
f"collinear_merge={args.merge} block_size={args.block_size} "
170+
f"collinear_merge={args.merge} block_size={args.block_size} hashes={args.hashes} "
150171
command += "common=False " if args.no_common else "common=True "
151172
command += "simplify_graph=False " if args.no_simplify_graph else "simplify_graph=True "
152173
command += "benchmark=True " if args.benchmark else "benchmark=False "

bin/ntsynt_run_pipeline.smk

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ references = config["references"] if "references" in config else "Must specify '
1111
k = config["kmer"] if "kmer" in config else 24
1212
w = config["window"] if "window" in config else 1000
1313
fpr = config["fpr"] if "fpr" in config else 0.025
14+
hashes = config["hashes"] if "hashes" in config else 3
1415
max_threads = config["threads"] if "threads" in config else 4
1516
prefix = config["prefix"] if "prefix" in config else "ntSynt_out"
1617
common = config["common"] if "common" in config else True
@@ -56,7 +57,7 @@ rule make_common_bf:
5657
input: refs=references
5758
output: f"{prefix}.common.bf"
5859
threads: max_threads
59-
params: options=f"-p {prefix}.common --fpr {fpr} -k {k}",
60+
params: options=f"-p {prefix}.common --fpr {fpr} --hashes {hashes} -k {k}",
6061
path_to_script=f"{script_path}/ntsynt_make_common_bf",
6162
benchmarking=f"{benchmark_path} -o {prefix}.make_common_bf.time" if benchmark else []
6263
shell: "{params.benchmarking} {params.path_to_script} --genome {input.refs} {params.options} -t {threads}"

src/ntsynt_make_common_bf.cpp

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ Creating a common Bloom filter (with k-mers found in all input assemblies) using
1515
a cascading Bloom filter approach
1616
*/
1717

18-
// Number of hash functions - keep at 1
19-
const unsigned HASH_FNS = 1;
2018
// Converting bits to bytes
2119
const unsigned NUM_BITS_PER_BYTE = 8;
2220

@@ -26,7 +24,7 @@ Using the formula found in Broder & Mitzenmacher, 2004, simplified for
2624
one hash function.
2725
*/
2826
long long
29-
approximate_bf_size(std::string genome_file, double fpr, int threads)
27+
approximate_bf_size(std::string genome_file, double fpr, int hashes, int threads)
3028
{
3129
long long genome_size = 0;
3230
btllib::SeqReader reader(
@@ -35,7 +33,9 @@ approximate_bf_size(std::string genome_file, double fpr, int threads)
3533
genome_size += record.seq.length();
3634
}
3735
std::cout << "Genome size (bp): " << genome_size << std::endl;
38-
long long size_bits = ceil(((-1 * genome_size) / log(1 - fpr)));
36+
double m_bits = -1.0 * hashes * genome_size / log(1.0 - pow(fpr, 1.0 / hashes));
37+
38+
long long size_bits = (long long) std::ceil(m_bits);
3939
return size_bits / NUM_BITS_PER_BYTE;
4040
}
4141

@@ -65,6 +65,11 @@ main(int argc, const char** argv)
6565
parser.add_argument("--bf")
6666
.help("Bloom filter size in bytes (optional)")
6767
.scan<'d', long long>();
68+
69+
parser.add_argument("--hashes")
70+
.help("Number of hash functions")
71+
.default_value(3U)
72+
.scan<'u', unsigned>();
6873

6974
parser.add_argument("-t")
7075
.help("Number of threads")
@@ -85,6 +90,7 @@ main(int argc, const char** argv)
8590
unsigned num_threads = parser.get<unsigned>("t");
8691
double fpr = parser.get<double>("fpr");
8792
unsigned k = parser.get<unsigned>("k");
93+
unsigned hashes = parser.get<unsigned>("hashes");
8894
std::string prefix = parser.get<std::string>("p");
8995

9096
std::cout << "Parameters:" << std::endl;
@@ -96,6 +102,7 @@ main(int argc, const char** argv)
96102
std::cout << "\t\t-t " << num_threads << std::endl;
97103
std::cout << "\t\t-k " << k << std::endl;
98104
std::cout << "\t\t--fpr " << fpr << std::endl;
105+
std::cout << "\t\t--hashes " << hashes << std::endl;
99106
std::cout << "\t\t-p " << prefix << std::endl;
100107

101108
#if _OPENMP
@@ -113,14 +120,14 @@ main(int argc, const char** argv)
113120
std::cout << "\t\t--bf " << bf_size << std::endl;
114121
} else {
115122
std::cout << "Calculating BF size based on input genome size" << std::endl;
116-
bf_size = approximate_bf_size(genome_files[0], fpr, num_threads);
123+
bf_size = approximate_bf_size(genome_files[0], fpr, hashes, num_threads);
117124
}
118125
std::cout << "BF size (bytes): " << bf_size << std::endl;
119126

120127

121128
/* Load the initial level 1 BF with k-mers from the first genome */
122129
btllib::KmerBloomFilter* bf =
123-
new btllib::KmerBloomFilter(bf_size, HASH_FNS, k);
130+
new btllib::KmerBloomFilter(bf_size, hashes, k);
124131
btllib::log_info("Reading " + genome_files[0]);
125132
btllib::SeqReader reader(
126133
genome_files[0], btllib::SeqReader::Flag::LONG_MODE, num_threads);
@@ -134,7 +141,7 @@ main(int argc, const char** argv)
134141
/* For each subsequent genome, only insert into next level BF if the k-mer is
135142
in the previous level BF. */
136143
btllib::KmerBloomFilter* new_bf =
137-
new btllib::KmerBloomFilter(bf_size, HASH_FNS, k);
144+
new btllib::KmerBloomFilter(bf_size, hashes, k);
138145

139146
size_t num_assemblies = genome_files.size();
140147
for (size_t i = 1; i < num_assemblies; ++i) {
@@ -144,7 +151,7 @@ main(int argc, const char** argv)
144151
genome, btllib::SeqReader::Flag::LONG_MODE, num_threads);
145152
#pragma omp parallel
146153
for (const auto record : reader) {
147-
btllib::NtHash nthash(record.seq, HASH_FNS, k);
154+
btllib::NtHash nthash(record.seq, hashes, k);
148155
while (nthash.roll()) {
149156
if (bf->contains(nthash.hashes())) {
150157
new_bf->insert(nthash.hashes());
@@ -155,7 +162,7 @@ main(int argc, const char** argv)
155162
delete bf;
156163
bf = new_bf;
157164
if (i < num_assemblies - 1) {
158-
new_bf = new btllib::KmerBloomFilter(bf_size, HASH_FNS, k);
165+
new_bf = new btllib::KmerBloomFilter(bf_size, hashes, k);
159166
}
160167
}
161168

0 commit comments

Comments
 (0)