Skip to content

Commit 9244048

Browse files
committed
Better normalization
1 parent 0d84e65 commit 9244048

3 files changed

Lines changed: 67 additions & 27 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ gcnvplot --version
2323

2424
`gcnvplot` expects GATK `CollectReadCounts` tables as input. These can be plain TSV files or gzipped TSV files, and must contain the columns `CONTIG`, `START`, `END`, and `COUNT`.
2525

26-
For `create-background`, provide a text file with one sample read-count path per line. This command writes a background cohort TSV with interval-wise normalized summary statistics.
26+
For `create-background`, provide a text file with one sample read-count path per line. This command writes a background cohort TSV with interval-wise normalized summary statistics and a per-interval baseline median.
2727

2828
For `plot`, provide one sample read-count file, one background TSV produced by `create-background`, and a genomic region such as `chr1:100-299`. This command writes an SVG plot showing the sample log2 signal relative to the background cohort.
2929

src/gcnvplot/core.py

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"CONTIG",
1818
"START",
1919
"END",
20+
"BASELINE_MEDIAN",
2021
"N",
2122
"BG_NORM_MEAN",
2223
"BG_NORM_MEDIAN",
@@ -114,17 +115,37 @@ def read_path_list(path: Path) -> list[Path]:
114115
return paths
115116

116117

117-
def normalization_factor(counts: dict[Interval, int]) -> float:
118-
"""Return the fixed median-based normalization factor."""
119-
values = [count for count in counts.values() if count > 0]
120-
if not values:
121-
raise ValueError("Cannot median-normalize a file with no positive counts")
122-
return median([float(value) for value in values])
118+
def interval_baselines(sample_counts: list[dict[Interval, int]]) -> dict[Interval, float]:
119+
"""Estimate a robust baseline for each interval from background samples."""
120+
interval_values: dict[Interval, list[float]] = defaultdict(list)
121+
for counts in sample_counts:
122+
for interval, count in counts.items():
123+
if count > 0:
124+
interval_values[interval].append(float(count))
125+
126+
baselines: dict[Interval, float] = {}
127+
for interval, values in interval_values.items():
128+
baselines[interval] = median(values)
129+
return baselines
130+
131+
132+
def size_factor_from_baseline(
133+
counts: dict[Interval, int], baselines: dict[Interval, float]
134+
) -> float:
135+
"""Return a DESeq-style median-of-ratios size factor."""
136+
ratios = [
137+
count / baseline
138+
for interval, count in counts.items()
139+
if count > 0 and (baseline := baselines.get(interval)) is not None and baseline > 0
140+
]
141+
if not ratios:
142+
raise ValueError("Cannot normalize a file without usable baseline intervals")
143+
return median(ratios)
123144

124145

125-
def normalized_counts(counts: dict[Interval, int]) -> dict[Interval, float]:
126-
"""Return interval counts divided by the median normalization factor."""
127-
factor = normalization_factor(counts)
146+
def normalized_counts(counts: dict[Interval, int], baselines: dict[Interval, float]) -> dict[Interval, float]:
147+
"""Return interval counts divided by the median-of-ratios size factor."""
148+
factor = size_factor_from_baseline(counts, baselines)
128149
return {interval: count / factor for interval, count in counts.items()}
129150

130151

@@ -182,27 +203,35 @@ def build_background(args: argparse.Namespace) -> int:
182203
if not files:
183204
raise SystemExit(f"{args.read_counts_list}: no read-count TSV paths found")
184205

206+
sample_counts = [parse_read_counts(path) for path in files]
207+
baselines = interval_baselines(sample_counts)
208+
185209
interval_values: dict[Interval, list[float]] = defaultdict(list)
186-
for path in files:
187-
counts = parse_read_counts(path)
188-
for interval, value in normalized_counts(counts).items():
210+
for counts in sample_counts:
211+
for interval, value in normalized_counts(counts, baselines).items():
189212
interval_values[interval].append(value)
190213

191214
args.output.parent.mkdir(parents=True, exist_ok=True)
192215
with args.output.open("w", newline="", encoding="utf-8") as handle:
193-
handle.write("# normalization=median\n")
216+
handle.write("# normalization=median-of-ratios\n")
217+
handle.write("# baseline=median-positive-count\n")
194218
handle.write(f"# samples={len(files)}\n")
195219
handle.write("# lower_percentile=5\n")
196220
handle.write("# upper_percentile=95\n")
197221
writer = csv.DictWriter(handle, fieldnames=BACKGROUND_FIELDS, delimiter="\t")
198222
writer.writeheader()
223+
written_intervals = 0
199224
for interval in sorted(interval_values, key=lambda item: (item[0], item[1], item[2])):
225+
baseline = baselines.get(interval)
226+
if baseline is None or baseline <= 0:
227+
continue
200228
values = interval_values[interval]
201229
contig, start, end = interval
202230
row = {
203231
"CONTIG": contig,
204232
"START": start,
205233
"END": end,
234+
"BASELINE_MEDIAN": baseline,
206235
"N": len(values),
207236
"BG_NORM_MEAN": sum(values) / len(values),
208237
"BG_NORM_MEDIAN": median(values),
@@ -211,25 +240,35 @@ def build_background(args: argparse.Namespace) -> int:
211240
"BG_NORM_P95": percentile(values, 95),
212241
}
213242
writer.writerow({key: fmt(value) for key, value in row.items()})
243+
written_intervals += 1
214244

215245
print(f"Background samples: {len(files)}")
216-
print(f"Background intervals: {len(interval_values)}")
246+
print(f"Background intervals: {written_intervals}")
217247
print(f"Wrote: {args.output}")
218248
return 0
219249

220250

221251
def rows_for_plot(args: argparse.Namespace) -> list[dict[str, object]]:
222252
"""Join one sample with the background for plotting."""
223253
counts = parse_read_counts(args.read_counts)
224-
sample_norm = normalized_counts(counts)
225254
background = parse_background(args.background)
255+
baselines = {
256+
interval: float(row["BASELINE_MEDIAN"])
257+
for interval, row in background.items()
258+
if float(row["BASELINE_MEDIAN"]) > 0
259+
}
260+
sample_norm = normalized_counts(counts, baselines)
226261
region = args.region
227262

228263
rows: list[dict[str, object]] = []
229264
missing_background = 0
230265
for interval in sorted(counts, key=lambda item: (item[0], item[1], item[2])):
231266
if interval[0] != region[0] or not overlaps(interval[1], interval[2], region[1], region[2]):
232267
continue
268+
baseline = baselines.get(interval)
269+
if baseline is None or baseline <= 0:
270+
missing_background += 1
271+
continue
233272
bg = background.get(interval)
234273
if bg is None:
235274
missing_background += 1

tests/test_cli.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ def test_build_background_writes_expected_summary(
7070
)
7171

7272
background_text = output_path.read_text(encoding="utf-8")
73-
assert "# normalization=median" in background_text
74-
assert "chr1\t100\t199\t2\t0.66666667\t0.66666667" in background_text
75-
assert "chr1\t200\t299\t2\t1.3333333\t1.3333333" in background_text
73+
assert "# normalization=median-of-ratios" in background_text
74+
assert "chr1\t100\t199\t12.5\t2\t12.5\t12.5\t0\t12.5\t12.5" in background_text
75+
assert "chr1\t200\t299\t25\t2\t25\t25\t0\t25\t25" in background_text
7676

7777
assert capsys.readouterr().out == (
7878
"Background samples: 2\n"
@@ -135,15 +135,16 @@ def test_plot_log2_ratio_writes_svg_with_zero_centered_axis(
135135
background_path.write_text(
136136
"\n".join(
137137
[
138-
"# normalization=median",
138+
"# normalization=median-of-ratios",
139+
"# baseline=median-positive-count",
139140
"# samples=2",
140141
"# lower_percentile=5",
141142
"# upper_percentile=95",
142-
"CONTIG\tSTART\tEND\tN\tBG_NORM_MEAN\tBG_NORM_MEDIAN\tBG_NORM_SD\tBG_NORM_P5\tBG_NORM_P95",
143-
"chr1\t100\t199\t2\t1\t1\t0.1\t0.8\t1.2",
144-
"chr2\t100\t199\t2\t1\t1\t0.1\t0.8\t1.2",
145-
"chr1\t200\t299\t2\t1\t1\t0.1\t0.8\t1.2",
146-
"chr2\t200\t299\t2\t1\t1\t0.1\t0.8\t1.2",
143+
"CONTIG\tSTART\tEND\tBASELINE_MEDIAN\tN\tBG_NORM_MEAN\tBG_NORM_MEDIAN\tBG_NORM_SD\tBG_NORM_P5\tBG_NORM_P95",
144+
"chr1\t100\t199\t12.5\t2\t12.5\t12.5\t0\t12.5\t12.5",
145+
"chr2\t100\t199\t0\t2\t1\t1\t0.1\t0.8\t1.2",
146+
"chr1\t200\t299\t25\t2\t25\t25\t0\t25\t25",
147+
"chr2\t200\t299\t0\t2\t1\t1\t0.1\t0.8\t1.2",
147148
]
148149
)
149150
+ "\n",
@@ -184,8 +185,8 @@ def test_plot_log2_ratio_writes_svg_with_zero_centered_axis(
184185
assert "chr2:100-199" not in svg
185186
assert "chr2:200-299" not in svg
186187
assert "Log2(sample/background)" in svg
187-
assert "SIGNAL=-1.3006595" in svg
188-
assert "SIGNAL=0.6727054" in svg
188+
assert "SIGNAL=-0.584386" in svg
189+
assert "SIGNAL=0.41489328" in svg
189190

190191
assert capsys.readouterr().out == (
191192
"Plotted intervals: 2\n"

0 commit comments

Comments
 (0)