breseq copy-number-variation extension. CNery reads per-reference coverage tables produced by breseq bam2cov and predicts copy-number variation (CNV) across the genome. Predictions are corrected for coverage biases introduced by sequencing chemistry (GC-content bias) and prokaryotic replication state during DNA isolation (origin-to-terminus / OTR bias).
Recent updates (latest commits):
- Coverage tables are the only input —
CNeryreads breseqbam2covcoverage tables and nothing else. It no longer needs a BAM or a reference FASTA, and never runsbreseqitself. The reference sequence it needs for GC content is already in the table's ownref_basecolumn. - CSV or TSV, full or
--total-only— all four shapes of table are read without being declared. The delimiter is detected from the file's own header row, and the column schema from its column names.--total-onlytables are the recommended input: they carry three coverage columns instead of eight, so they are markedly smaller, and CNery loses nothing by using them. - Files and folders on the command line — name coverage tables directly, or name folders and let
CNeryfind them by file ending (--file-ending, defaultscoverage.csv,coverage.tsvandcoverage.tab). Files and folders can be mixed in one command. - Multi-genome CNV analysis —
CNeryprocesses all the coverage tables given in one pass. Each reference (chromosome, plasmid, contig, etc.) is preprocessed separately, pooled for a shared LOWESS GC-bias fit, and then bias-corrected and CN-called independently. - Cumulative GC skew origin/terminus prediction —
CNerynow also locates the replication origin and terminus from the reference's own cumulative GC skew (Grigoriev 1998), independently of read depth. Reported inGC_skew/as a marked-up plot and a JSON summary, and used as the fallback origin/terminus when the coverage profile carries no usable gradient of its own. - Output flexibility — output prefix defaults to
CNV_out/in the current folder. Output subfolders (CNV_plt/,CNV_csv/,GC_bias/,OTR_corr/,GC_skew/) are created automatically. - Modular bias correction — the
--biasflag lets you chooseall(GC + OTR),gc,otr, ornone. - A per-base segment-length prior —
--change-rateis the probability per base that copy number changes, so re-tiling a genome with different-w/-sdoes not restate the biology. Read1/rateas the expected segment length. - Pip-installable package —
requirements.txtand a fixedpyproject.tomlallow install directly from GitHub viapip install git+....
Recommended: create a conda/mamba environment from the provided spec.
mamba env create -f environment.yml
mamba activate CNeryInstall CNery (a.k.a. breseq-ext-cnv) from GitHub:
pip install git+https://github.com/barricklab/breseq-ext-cnv.gitCNery takes coverage tables — see Generating a coverage table if
you do not have them yet. Point it at the folder holding them:
CNery <folder> [-o <output folder>] [-w <window>] [-s <step size>] [-f <fragment length>]Run with no arguments at all and it reads the current folder. You can also name tables directly, or mix files and folders in one command:
CNery REL606.coverage.csv pPlasmid.coverage.csv -o CNV_out
CNery coverage/ extra/pContig.coverage.csv -o CNV_outFolders are searched, top level only, for files ending in coverage.csv, coverage.tsv or
coverage.tab — all three are found by default, and they may sit side by side. (.tab is the
legacy extension breseq's deprecated --table flag writes; its contents are ordinary TSV.) Use
--file-ending if your tables are named otherwise; repeat the flag to accept several. Note that any --file-ending replaces the defaults
rather than adding to them:
CNery coverage/ --file-ending cov.txt
CNery coverage/ --file-ending cov.txt --file-ending coverage.csvA table's sequence ID comes from its file name, with the matched ending and the . in front of it
removed — REL606.coverage.csv becomes REL606, and NC_012967.1.coverage.tsv becomes
NC_012967.1. That ID names every output file, and no two inputs may share one.
Everything passed in one command is analyzed together, sharing a single GC-bias fit and one global coverage median. That is what you want for the references of one sample — chromosome, plasmids and contigs. Analyze separate samples with separate commands.
Calculate coverage with a 500 bp window sliding in 250 bp steps; sequencing fragment length is 300 bp:
CNery <inputs> -o <output folder> -w 500 -s 250 -f 300Analyze coverage across the whole genome, but restrict the CNV plot to a specific genomic segment:
CNery <inputs> -o <output folder> --region REL606:3497890-3955678 -w 1000 -s 500The sequence ID is the one derived from the table's file name. SEQ_ID: may be omitted when the run
has only one input sequence:
CNery REL606.coverage.csv -o CNV_out --region 3497890-3955678Repeat the flag to plot several sequences, at most once each:
CNery coverage/ -o CNV_out --region REL606:3497890-3955678 --region pPlasmid:1-40000Open intervals work too — REL606:3497890- runs to the end of that sequence, REL606:-3955678 from
its start.
Two things to know. Giving any --region also selects which sequences are plotted: a sequence
not named gets no CNV plot. And --region affects plotting only — coverage, bias fitting and
copy-number calling always cover every sequence, and the output CSVs always contain every window for
every sequence, plotted or not.
Control which bias correction is applied before CN prediction:
# Both GC + OTR corrections (default)
CNery <inputs> -o <output folder> -w 500 -s 250 --bias all
# Only correct OTR (replication) bias
CNery <inputs> -o <output folder> -w 500 -s 250 --bias otr
# Only correct GC-content bias
CNery <inputs> -o <output folder> -w 500 -s 250 --bias gc
# No bias correction before CN prediction
CNery <inputs> -o <output folder> -w 500 -s 250 --bias noneWhen OTR correction is applied, the origin and terminus of replication are automatically inferred — no manual coordinates are required. CNery fits them from the coverage profile, falling back to the reference's own cumulative GC skew when the coverage carries no usable gradient. Either way the correction is applied only if it beats a bootstrap null, so a flat genome is left alone.
Coverage tables are CNery's only input. Generate them once with breseq bam2cov, then run CNery
against them as often as you like — no BAM, no reference FASTA, and breseq need not be installed
on the machine that runs CNery. The reference sequence CNery needs for GC content is already in
each table's ref_base column.
The conventional layout is one table per reference sequence, named <seq_id>.coverage.csv (or
.coverage.tsv — both are found by default):
coverage/
├── REL606.coverage.csv
└── pPlasmid.coverage.csv
CNery coverage/ -o CNV_outRequires pre-release breseq, the Barrick lab channel of
development builds auto-built from barricklab/breseq master. Released versions on bioconda do not
include the current fixes to how coverage tables are written.
conda install -c https://barricklab.github.io/conda/ -c conda-forge -c bioconda breseq-prereleaseOmit --region and breseq writes one table per reference sequence automatically — no need to look
up sequence IDs or lengths. --output is then a directory:
mkdir -p coverage
breseq bam2cov \
--format CSV \
--total-only \
--resolution 0 \
--output coverage \
-b data/reference.bam \
-f data/reference.fastaTo generate a single table instead, name the region using the sequence ID exactly as it appears in
the FASTA, and give --output a name ending in .coverage.csv:
breseq bam2cov \
--format CSV \
--total-only \
--region REL606:1-4629812 \
--resolution 0 \
--output coverage/REL606.coverage.csv \
-b data/reference.bam \
-f data/reference.fastaFour options matter:
--resolution 0outputs every position. The default is 600, which samples only 600 points across the whole region — far too sparse for windowed coverage, and it fails silently by producing a well-formed but nearly empty table.--format CSVproduces a comma-separated table instead of a plot;TSVgives the same columns tab-separated.CNeryreads either and works out which from the file itself, so the choice is yours. (The old-t/--tableflag is deprecated: it still selects TSV, but writes the legacy.tabextension.CNerymatches that too, but prefer--format TSV.)--total-only(short flag-1) writesunique_cov,redundant_covandtotal_covin place of the eight strand-split coverage columns — roughly 2.5× smaller files.breseqsums the strands itself, and those sums are exactly whatCNerycomputes from the wider table, so nothing CNery uses is lost: repeat detection viaredundant_covstill works, and copy-number calls are unchanged. Recommended.--per-read-group(optional) repeats every coverage column once per read group (@RG) in the BAM, prefixedRG-<n>_where<n>is the read group's index in the BAM header. Requires a table format; it is rejected with--format PNG. A BAM with no read groups yields a singleRG-0set.
breseq bam2cov \
--format CSV \
--total-only \
--per-read-group \
--resolution 0 \
--output coverage \
-b data/reference.bam \
-f data/reference.fastaCNery reads such a table without any change. The aggregate columns keep their names and positions
and the per-read-group repeats are appended, so the columns CNery needs are still where it
expects them; it selects by name and ignores the rest. The per-read-group summary lines added to the
footer are stripped along with the others by their # prefix. Nothing in CNery consumes the
per-read-group columns today — they pass through — so the option is safe to enable now if you want
per-library coverage available in the same file for other tools.
The output carries a header row, one row per reference position, and a trailing #-commented summary
block. With --total-only --format CSV:
position,ref_base,unique_cov,redundant_cov,total_cov
1,G,32,0,32
2,G,32,0,32
...
#,region_unique_average_cov,56.7267
#,region_repeat_average_cov,0
#,region_average_cov,56.7267
#,number_of_positions,4629812
Without --total-only, each count is split by strand and six further columns follow:
position ref_base unique_top_cov unique_bot_cov redundant_top_cov redundant_bot_cov ...
1 G 14 18 0 0 ...
The delimiter is the only difference between --format CSV and --format TSV; it is used for the
header, the data and the footer alike.
That summary block is variable in length — --show-average adds a line, and --per-read-group adds
three per group (# RG-0_region_unique_average_cov …) — so anything parsing these tables should
skip lines by their # prefix rather than dropping a fixed number from the end.
For the same reason, do not assume a fixed column count: position must be the first column and the
named coverage columns must be present, but extra columns to the right are expected and should be
ignored rather than treated as an error.
CNery needs position, ref_base, and unique-versus-redundant coverage in one of the two shapes
above — unique_cov + redundant_cov, or the four strand-split unique_*/redundant_* columns. It
checks for them as soon as a table is opened, so a file with the wrong schema is rejected by name
instead of failing later. total_cov is ignored: it is unique_cov + redundant_cov by construction.
Note that breseq's own 08_mutation_identification/*.coverage.tab files use a different schema
(position last, no ref_base) and are not usable as CNery input.
If you do need the sequence IDs and lengths for a --region argument, read them from the FASTA
headers or the .fai index:
grep '^>' data/reference.fasta
cut -f1,2 data/reference.fasta.faiGiven an output folder CNV_out/, CNery writes:
The -f fragment size is optional. GC bias acts at the scale of the sequenced
fragment, so that size belongs to the library rather than to the analysis — and
it is not something a coverage table shows you. Left unset, CNery scores
candidate sizes by how well the GC each implies predicts held-out coverage (with
the replication ramp divided out and copy-number variants excluded, so neither
can be mistaken for a GC effect) and reports what it chose. The 400 bp default is
kept unless a candidate beats it by more than the measurement's own error. Pass
-f to pin it.
CNery corrects coverage and calls copy number in two passes. The first runs GC correction, origin-to-terminus correction and the HMM as usual. The second repeats both fits with every window the first pass did not call single-copy excluded from them, then calls copy number again — only the second pass's results are written.
An amplification is invisible to the crude censoring the first pass has available (near-zero depth, repeat overlap), so it otherwise sits in the GC and ramp fits at full weight and distorts them. Measured on synthetic coverage where the truth is known, a real 1.5x replication ramp with an amplification on top is detected 0 times in 60 without this and 60 times in 60 with it, at no cost in false positives. If excluding the non-single-copy windows would leave under half a sequence, it is skipped for that sequence and reported as such.
-
CNV_out/CNV_plt/— per-reference CNV prediction plots. -
CNV_out/CNV_csv/— per-window coverage + CN calls as CSV. -
The GC correction is a fitted curve, not an exact quantity, so how well it is determined at each window's GC is measured (by resampling the fit) and carried into the copy-number model as extra variance. The effect grows with copy number, because a correction factor's error is multiplied by the number of copies — which is why a window at an extreme GC inside an amplification is no longer able to earn its own copy-number segment on the strength of the correction alone.
-
CNV_out/GC_bias/also holds*_GC_passes.pdf— the GC correction is fitted in two pooled passes, once on raw coverage and again after OTR correction (which reintroduces a GC trend, because the replication ramp varies with position and position correlates with GC). The second pass additionally excludes every window the first pass's copy-number calls did not put at CN=1. The plot shows both curves and their product, which is the total correction actually applied. -
CNV_out/GC_bias/— pooled LOWESS GC-bias diagnostic plot. -
CNV_out/corr_plots/— per-reference before/after diagnostic (*_correction_stages.pdf): one row for each correction step — GC and OTR, in each of the two passes — showing coverage before and after it, with the fitted curve overlaid, and directly beneath each row a strip of which windows that particular fit was allowed to see. Deletions are drawn as spans, repeats as a density track, and the second pass's strips additionally mark everything called CN≠1. One row does not continue from the one above it, and says so: the second OTR fit divides the first pass's ramp back out, because a ramp has to be fitted to coverage that still contains it. Each row is labelled with the fraction of windows within 20% of single copy before and after, reported both over all windows and over uncensored windows only — the two can differ a lot on a repeat-heavy replicon, and the strip below shows why. Produced in every--biasmode. -
CNV_out/OTR_corr/— per-reference OTR bias plots and a JSON summary (*_otr_results.json) containing the inferred origin window, terminus window, normalized coverage at each, the origin-to-terminus ratio, and the sequence's relative copy number.This file is written for every reference in every
--biasmode, including ones where no correction was attempted and ones with no usable coverage at all. A reference whose table has no position rows, or whose every window reads zero, is reported rather than treated as an error: it gets the ratio"Not detected", a"No usable coverage reason"saying which of the two it was, and its own (empty) CSVs and plots, and the run continues with the other references and exits 0. A plasmid that got no reads does not cost you the chromosome sequenced alongside it.It also reports how well the applied ramp actually fits, beyond r²:
"Residual structure score"asks whether what the tent failed to explain is systematically structured rather than just noisy — a fit can explain a lot of variance and still be the wrong shape over a long stretch. It is a z-score against a bootstrap null, so read it as roughly: below 1 unstructured, 1–2 mild, above 2 structured."Residual decorrelation length (bp)"is the scale of correlation that would be present anyway, published alongside so the score can be judged in context. Both are diagnostic only — nothing in CNery acts on them.It also records the evidence behind the decision, whether or not a correction was applied:
"Coverage fit r-squared"/"Coverage fit p-value", the same pair for the GC-skew-anchored fit, the"Coverage vs skew likelihood ratio"and its p-value when both candidates were live,"Bootstrap surrogates", and"Breakpoint source"(coverage fit,GC skew, ornot corrected). A rejected fit is therefore diagnosable from the file alone. Note the p-values are floored at1/(surrogates+1), so0.001is an upper bound rather than a measurement."Relative copy number"is that sequence's coverage relative to the longest sequence in the run, which reads exactly1.0. It is not rounded to an integer: a plasmid at2.95is a measurement, and it is the only place plasmid copy number is reported —prob_copy_numberin the CSVs is called per reference, so a uniformly multi-copy plasmid comes out as1there. -
CNV_out/GC_skew/— per-reference cumulative GC-skew plots with the predicted origin and terminus marked, and a JSON summary (*_gc_skew_results.json).
Each coverage table produces its own set of outputs, named with the sequence ID derived from its file name. The GC-bias plot is the exception: one pooled fit covers every table in the run.
Bacterial genomes are G-rich on the leading strand and C-rich on the lagging strand, so the sign of the GC skew (G−C)/(G+C) flips at the two points where the replication strands switch. Summing the skew along the genome turns those sign changes into extrema: following Grigoriev 1998, the cumulative curve reaches its minimum over the replication origin and its maximum at the terminus.
CNery computes this from the coverage table's ref_base column — no FASTA and no read depth involved — and writes the result for every reference, in all four --bias modes:
{
"Origin (bp)": 3885501,
"Terminus (bp)": 1526001,
"Origin window index": 7771,
"Terminus window index": 3052,
"Windows": 9258,
"Separation (fraction of genome)": 0.4903,
"Cumulative skew amplitude": 151.2949,
"Replichore skew t-statistic": 34.77,
"Replichore skew p-value": 0.001,
"Bootstrap surrogates": 1000,
"Prediction confident": true,
"Prediction method": "Ori-ter coordinates from cumulative GC skew (Grigoriev 1998)"
}Prediction confident requires two things: the two extrema roughly antipodal (35–65% of the sequence apart, as bidirectional replication implies), and a p-value of 0.01 or better. The coordinates are reported either way — a low-confidence call stays diagnosable from the JSON and the plot rather than being reduced to a flag.
Adjacent windows are not independent — genome composition varies on scales far longer than one window — so an ordinary t-test would badly overstate significance. Replichore skew t-statistic is therefore reported as an effect size only; its magnitude is inflated by an unknown factor and should not be converted to a p-value.
The p-value instead comes from a circular block bootstrap. Contiguous blocks of windows are resampled with replacement around the circle, which preserves local autocorrelation while destroying the long-range two-arm pattern being tested for; the null is "a sequence that wobbles like this one but has no single origin". The full procedure — locating the extrema, then scoring the two arms — is re-run on every surrogate, so choosing the breakpoints by looking at the data is paid for rather than ignored.
Two things to know when reading it:
- It is floored at
1/(B+1). A real chromosome beats all 1,000 surrogates and reads back exactly0.001. That is an upper bound, not a measurement — read it as "p < 0.001".Bootstrap surrogatesis reported so the floor is visible. - Block length adapts to sequence length. What governs power is the number of blocks rather than their size, so CNery targets ~20 blocks, bounded to 10–200 windows each. Sequences too short to give both long-enough and numerous-enough blocks simply do not reach significance, which is an honest reflection of how little evidence they carry.
The bootstrap is seeded, so repeated runs on the same input give the same p. It adds roughly 3% to the per-sequence cost (~0.15 s on a 4.6 Mb genome).
Expect false on plasmids. They have no bidirectional replication origin, so there is no sign change for the cumulative curve to turn on, and the two extrema you get back are noise — the two in the test data land at p = 0.36 and 0.44. That is the intended answer, not a failure, and chromosomes in the same run are unaffected since the prediction is made per reference.
Two properties worth knowing. The prediction depends only on the reference, so different samples aligned to the same reference give identical answers, whatever their depth or growth phase. And it is invariant to circular permutation: a reference whose coordinates start elsewhere predicts the same locus.
These values feed the OTR correction as its second candidate. CNery prefers the coverage-derived origin and terminus when they clear their own significance test and a likelihood-ratio test says they fit better than a ramp hinged at the GC-skew coordinates; otherwise it uses the skew's, provided the skew prediction is confident and the coverage does not contradict which end is the origin. "Correction type" and "Breakpoint source" in *_otr_results.json say which was used.
The practical consequence: a sequence with no replication gradient in its coverage but a confident skew prediction now receives a small correction where it previously received none. The magnitude is still fitted from the coverage, not imported — on a genuinely flat sequence the fitted ramp is close to 1.0 — but the evidence for such a correction is the reference sequence, not the reads. "GC skew fit p-value" reports what the coverage itself had to say about it.
$ CNery -h
usage: CNery [-h] [--file-ending ENDING] [--region SEQ_ID:START-END] [-o O]
[-w W] [-s S] [-f F]
[-z DELETION_COVERAGE_FRACTION] [--change-rate CHANGE_RATE]
[--bias {all,none,gc,otr}]
[INPUT ...]
CNery is a Python package extension to breseq that analyzes the sequencing
coverage across the genome to predict copy number variation (CNV).
positional arguments:
INPUT Coverage table files, and/or folders containing them.
Folders are searched (top level only) for files
ending in --file-ending. Every table given is
analyzed together, sharing one GC-bias fit, so these
should be the reference sequences of a single sample.
Defaults to the current folder.
options:
-h, --help show this help message and exit
--file-ending ENDING File ending that identifies a coverage table inside
an input folder. Repeat the flag to accept more than
one. Any --file-ending REPLACES the defaults
('coverage.csv', 'coverage.tsv', 'coverage.tab')
rather than adding to them. A file named directly on
the command line is always used, whatever it is
called.
--region SEQ_ID:START-END
Plot the CNV calls for one sequence over a genomic
segment, e.g. 'REL606:3497890-3955678'. The sequence
ID is the one derived from the table's file name.
Repeat the flag to plot several sequences, at most
once each. 'SEQ_ID:' may be omitted when the run has
only one input sequence. Open intervals are accepted:
'REL606:3497890-' runs to the end of the sequence,
'REL606:-3955678' from its start. Giving any --region
also selects WHICH sequences are plotted: those not
named get no CNV plot. This affects plotting only --
coverage, bias fitting and copy-number calling always
cover every sequence, and the output CSVs always
contain every window.
-o, --output O output file prefix / storage location. Defaults to
the 'CNV_out' folder in the current dir.
-w, --window W Window length used to parse the genome and compute
coverage and GC statistics. Default: 100. Wider
windows smooth the coverage but lose short events: the
window statistic is a per-base median, whose precision
grows sublinearly with width, so -w is a resolution
knob.
-s, --step-size S Step size (<= window size) for each progression of
the window across the genome. Set step-size = window
size for non-overlapping windows. Default: 100, i.e.
non-overlapping. Copy-number calls are near-invariant
to this: the state-change prior is per base (see
--change-rate) and overlapping windows are
down-weighted so they do not count the same bases
twice.
-f, --frag-size F Average fragment size of the sequencing library. GC%
is measured over this many bases centred on each
window. Ignored when smaller than -w. Default: 400.
-z, --deletion-coverage-fraction DELETION_COVERAGE_FRACTION
Coverage a deleted region still shows, as a fraction of
the single-copy level. Sets the mean of the
copy-number-0 emission. Real deletions are not empty --
mismapping and repeat spill leave a couple of percent
behind. A fraction rather than an absolute depth so
that what counts as a deletion does not change with how
deeply the sample was sequenced. Default: 0.02.
--change-rate CHANGE_RATE
Prior probability PER BASE that copy number changes.
The per-window probability is 1 - exp(-rate * step-
size), so changing -w/-s no longer changes the
implied biology. Read 1/rate as the expected segment
length: the default 1e-06 is one copy-number boundary
per megabase. Larger values give more, shorter
segments.
--bias {all,none,gc,otr}
Select which bias correction to apply before CN
prediction. 'all' applies GC + OTR, 'gc' or 'otr'
applies only that one, 'none' skips bias correction.
Default: all.
Inputs are breseq 'bam2cov' coverage tables (CSV or TSV). Run with no
arguments in a folder that holds them, or name files and/or folders directly.
The test suite has two tiers, and a bare pytest runs both. Set up the development environment
first:
conda env create -f dev-environment.yml --prefix=$PWD/env
conda run -p $PWD/env pytest # everything
conda run -p $PWD/env pytest -m synthetic # fast, offline
conda run -p $PWD/env pytest -m authentic # real data onlySynthetic tier — DataFrames constructed in tests/conftest.py, staged to match the pipeline's
column contract at each stage. Fast, self-contained, no network. Use -m synthetic as the inner
loop while editing.
Authentic tier — real breseq coverage tables published as GitHub Release assets: genuine
coordinate gaps, repeat regions, per-read-group columns, and copy-number variation that synthetic
frames cannot reproduce. On a cold cache the first run downloads ~105 MB, then caches it.
Running both by default is deliberate: an opt-in tier is one people forget, and real-data coverage then lapses without anyone noticing. If you want the fast path, ask for it explicitly.
Each dataset is pinned by sha256 in tests/data/registry.json, so an asset replaced in place fails
the hash check rather than silently changing what the tests measure. Downloads are cached by
pooch; set CNERY_TESTDATA_DIR to relocate that cache.
Datasets ship coverage tables but no BAM — coverage tables are all CNery reads, which keeps
them small.
An unavailable dataset causes those tests to skip rather than fail, so offline work stays possible. That means a default run without network access reports passes and skips together — check for skips rather than assuming green means everything ran.
Adding tests, publishing datasets, and updating golden files are covered in
DEVELOPER.