Skip to content

Commit 3dff3e4

Browse files
authored
Merge pull request #540 from nextstrain/cleanup-generate-metadata
Stream generate_metadata join and split clock_deviation into its own rule
2 parents 3f6ce81 + 62a1042 commit 3dff3e4

3 files changed

Lines changed: 213 additions & 80 deletions

File tree

bin/compute-clock-deviation

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Append a `clock_deviation` column to the joined metadata.
4+
5+
`clock_deviation` is a per-clade molecular-clock QC metric: each sequence's
6+
divergence minus its clade's clock-expected divergence for its collection date.
7+
It only needs three columns -- `date`, `divergence`, `Nextstrain_clade` -- so
8+
computing it in its own step keeps the (much larger) metadata/Nextclade join a
9+
flat-memory streaming operation. The calculation here is the one that used to
10+
live in `bin/join-metadata-and-clades`, unchanged.
11+
"""
12+
import argparse
13+
import sys
14+
from datetime import datetime
15+
16+
import numpy as np
17+
import pandas as pd
18+
19+
CLADE_COLUMN = "Nextstrain_clade"
20+
CLOCK_DEVIATION_COLUMN = "clock_deviation"
21+
VALUE_MISSING_DATA = '?'
22+
23+
rate_per_day = 0.0007 * 29903 / 365
24+
reference_day = datetime(2020, 1, 1).toordinal()
25+
26+
27+
def parse_args():
28+
parser = argparse.ArgumentParser(
29+
description="Append a clock_deviation column to the joined metadata TSV.",
30+
)
31+
parser.add_argument("--metadata", required=True,
32+
help="Joined metadata TSV (output of join-metadata-and-clades)")
33+
parser.add_argument("-o", default=sys.stdout,
34+
help="Output metadata TSV with clock_deviation appended")
35+
return parser.parse_args()
36+
37+
38+
def datestr_to_ordinal(x):
39+
try:
40+
return datetime.strptime(x, "%Y-%m-%d").toordinal()
41+
except:
42+
return np.nan
43+
44+
45+
def isfloat(value):
46+
try:
47+
float(value)
48+
return True
49+
except ValueError:
50+
return False
51+
52+
53+
def main():
54+
args = parse_args()
55+
56+
# Pass 1: read only the three columns needed and compute clock_deviation
57+
# (verbatim from the former join-metadata-and-clades logic).
58+
result = pd.read_csv(args.metadata, sep='\t',
59+
usecols=["date", "divergence", CLADE_COLUMN],
60+
dtype="object", na_filter=False)
61+
62+
all_clades = result[CLADE_COLUMN].unique()
63+
t = result["date"].apply(datestr_to_ordinal)
64+
div_array = np.array([float(x) if isfloat(x) else np.nan for x in result.divergence])
65+
offset_by_clade = {}
66+
for clade in all_clades:
67+
ind = result[CLADE_COLUMN] == clade
68+
if ind.sum() > 100:
69+
deviation = div_array[ind] - (t[ind] - reference_day) * rate_per_day
70+
offset_by_clade[clade] = np.mean(deviation[~np.isnan(deviation)])
71+
72+
offset = result[CLADE_COLUMN].apply(lambda x: offset_by_clade.get(x, 2.0))
73+
clock_deviation = np.array(div_array - ((t - reference_day) * rate_per_day + offset), dtype=int)
74+
# Match the former int->float upcast (nan assignment) so e.g. 5 renders as "5.0".
75+
clock_deviation = clock_deviation.astype(float)
76+
clock_deviation[np.isnan(div_array) | np.isnan(t)] = np.nan
77+
78+
clock_strings = [VALUE_MISSING_DATA if np.isnan(v) else str(v) for v in clock_deviation]
79+
del result, t, div_array, offset
80+
81+
# Pass 2: stream the input and append clock_deviation as the last column.
82+
# Appending raw bytes preserves the join output exactly; '?' / '5.0' never
83+
# need quoting.
84+
out_is_path = isinstance(args.o, str)
85+
out_fh = open(args.o, 'w', newline='') if out_is_path else args.o
86+
try:
87+
with open(args.metadata, newline='') as fin:
88+
header = fin.readline().rstrip('\n')
89+
out_fh.write(f"{header}\t{CLOCK_DEVIATION_COLUMN}\n")
90+
for value, line in zip(clock_strings, fin):
91+
out_fh.write(f"{line.rstrip(chr(10))}\t{value}\n")
92+
finally:
93+
if out_is_path:
94+
out_fh.close()
95+
96+
97+
if __name__ == '__main__':
98+
main()

bin/join-metadata-and-clades

Lines changed: 99 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
#!/usr/bin/env python3
22
import argparse
3+
import csv
4+
import os
5+
import subprocess
36
import sys
4-
from datetime import datetime
5-
import pandas as pd
6-
import numpy as np
7+
import tempfile
78
import yaml
89

910
INSERT_BEFORE_THIS_COLUMN = "pango_lineage"
@@ -12,8 +13,8 @@ METADATA_JOIN_COLUMN_NAME = 'strain'
1213
NEXTCLADE_JOIN_COLUMN_NAME = 'seqName'
1314
VALUE_MISSING_DATA = '?'
1415

15-
rate_per_day = 0.0007 * 29903 / 365
16-
reference_day = datetime(2020,1,1).toordinal()
16+
# Nextclade mutation lists (e.g. aaSubstitutions) can be long; lift the csv limit.
17+
csv.field_size_limit(10 ** 7)
1718

1819
column_map = {
1920
"clade_nextstrain": "clade_nextstrain",
@@ -41,24 +42,6 @@ column_map = {
4142
"aaSubstitutions": "aaSubstitutions"
4243
}
4344

44-
# Nextstrain_clade is added later based on clade_nextstrain and a yml mapping
45-
new_columns = list(column_map.values()) + ["Nextstrain_clade"]
46-
47-
48-
def reorder_columns(result: pd.DataFrame):
49-
"""
50-
Moves COLUMN_TO_REORDER right before INSERT_BEFORE_THIS_COLUMN
51-
"""
52-
if COLUMN_TO_REORDER not in new_columns:
53-
raise ValueError(f"Column {COLUMN_TO_REORDER} not found in values of column_map {column_map}")
54-
columns = list(result.columns)
55-
if INSERT_BEFORE_THIS_COLUMN not in columns:
56-
raise ValueError(f"Column {INSERT_BEFORE_THIS_COLUMN} not found in metadata columns {columns}")
57-
columns.remove(COLUMN_TO_REORDER)
58-
insert_at = columns.index(INSERT_BEFORE_THIS_COLUMN)
59-
columns.insert(insert_at, COLUMN_TO_REORDER)
60-
return result[columns]
61-
6245

6346
def parse_args():
6447
parser = argparse.ArgumentParser(
@@ -70,73 +53,110 @@ def parse_args():
7053
parser.add_argument("-o", default=sys.stdout)
7154
return parser.parse_args()
7255

73-
def datestr_to_ordinal(x):
74-
try:
75-
return datetime.strptime(x,"%Y-%m-%d").toordinal()
76-
except:
77-
return np.nan
7856

79-
def isfloat(value):
80-
try:
81-
float(value)
82-
return True
83-
except ValueError:
84-
return False
57+
def read_header(path):
58+
with open(path, newline='') as fh:
59+
return next(csv.reader(fh, delimiter='\t'))
60+
61+
62+
def sort_tsv_by_column(path, key_index, out_dir):
63+
"""
64+
Sort a TSV by its (0-based) `key_index` column, keeping the header first,
65+
using an external `LC_ALL=C sort` (bytewise, spills to disk → flat memory).
66+
Returns the path to a temp file the caller must unlink.
67+
"""
68+
fd, out_path = tempfile.mkstemp(suffix='.sorted.tsv', dir=out_dir)
69+
os.close(fd)
70+
with open(path, newline='') as fin, open(out_path, 'w', newline='') as fout:
71+
fout.write(fin.readline()) # header
72+
with open(out_path, 'a', newline='') as fout:
73+
tail = subprocess.Popen(["tail", "-n", "+2", path], stdout=subprocess.PIPE)
74+
subprocess.run(
75+
["sort", "-t", "\t", f"-k{key_index + 1},{key_index + 1}"],
76+
stdin=tail.stdout, stdout=fout,
77+
env={**os.environ, "LC_ALL": "C"}, check=True,
78+
)
79+
tail.stdout.close()
80+
tail.wait()
81+
return out_path
82+
8583

8684
def main():
8785
args = parse_args()
8886

89-
metadata = pd.read_csv(args.metadata, index_col=METADATA_JOIN_COLUMN_NAME,
90-
sep='\t', low_memory=False, na_filter = False)
87+
out_is_path = isinstance(args.o, str)
88+
out_dir = os.path.dirname(os.path.abspath(args.o)) if out_is_path else "."
9189

92-
clades = pd.read_csv(args.nextclade_tsv, index_col=NEXTCLADE_JOIN_COLUMN_NAME,
93-
usecols=[NEXTCLADE_JOIN_COLUMN_NAME, *column_map.keys()],
94-
sep='\t', low_memory=True, dtype="object", na_filter = False) \
95-
.rename(columns=column_map)
96-
97-
# Add clade_legacy column as Nextstrain_clade
98-
# Use yml mapping
99-
with open(args.clade_legacy_mapping, 'r') as legacy_mapping_file:
100-
clade_legacy_mapping_dict: dict[str, str] = yaml.safe_load(legacy_mapping_file)
90+
metadata_header = read_header(args.metadata)
91+
nextclade_header = read_header(args.nextclade_tsv)
10192

102-
def clade_legacy_mapping(clade_nextstrain: str) -> str:
103-
return clade_legacy_mapping_dict.get(clade_nextstrain, f"{clade_nextstrain} (Omicron)")
104-
105-
clades["Nextstrain_clade"] = clades["clade_nextstrain"].map(clade_legacy_mapping)
93+
strain_idx = metadata_header.index(METADATA_JOIN_COLUMN_NAME)
94+
seqname_idx = nextclade_header.index(NEXTCLADE_JOIN_COLUMN_NAME)
95+
# Source column indices of the mapped Nextclade columns, in column_map order.
96+
clade_src_idx = [nextclade_header.index(k) for k in column_map.keys()]
10697

107-
clades = clades[new_columns]
98+
# Metadata columns (header order, minus the strain index column) and where to
99+
# read each from a metadata row.
100+
metadata_cols = [c for c in metadata_header if c != METADATA_JOIN_COLUMN_NAME]
101+
metadata_src_idx = [i for i, c in enumerate(metadata_header) if c != METADATA_JOIN_COLUMN_NAME]
102+
# Nextstrain_clade is spliced into the metadata block, right before pango_lineage.
103+
splice_at = metadata_cols.index(INSERT_BEFORE_THIS_COLUMN)
108104

109-
# Concatenate on columns
110-
result = pd.merge(
111-
metadata, clades,
112-
left_index=True,
113-
right_index=True,
114-
how='left'
105+
output_header = (
106+
[METADATA_JOIN_COLUMN_NAME]
107+
+ metadata_cols[:splice_at] + [COLUMN_TO_REORDER] + metadata_cols[splice_at:]
108+
+ list(column_map.values())
115109
)
116110

117-
all_clades = result.Nextstrain_clade.unique()
118-
t = result["date"].apply(datestr_to_ordinal)
119-
div_array = np.array([float(x) if isfloat(x) else np.nan for x in result.divergence])
120-
offset_by_clade = {}
121-
for clade in all_clades:
122-
ind = result.Nextstrain_clade==clade
123-
if ind.sum()>100:
124-
deviation = div_array[ind] - (t[ind] - reference_day)*rate_per_day
125-
offset_by_clade[clade] = np.mean(deviation[~np.isnan(deviation)])
126-
127-
# extract divergence, time and offset information into vectors or series
128-
offset = result["Nextstrain_clade"].apply(lambda x: offset_by_clade.get(x, 2.0))
129-
# calculate divergence
130-
result["clock_deviation"] = np.array(div_array - ((t-reference_day)*rate_per_day + offset), dtype=int)
131-
result.loc[np.isnan(div_array)|np.isnan(t), "clock_deviation"] = np.nan
132-
133-
for col in new_columns + ["clock_deviation"]:
134-
result[col] = result[col].fillna(VALUE_MISSING_DATA)
135-
136-
# Move the new column so that it's next to other clade columns
137-
result = reorder_columns(result)
138-
139-
result.to_csv(args.o, index_label=METADATA_JOIN_COLUMN_NAME, sep='\t')
111+
with open(args.clade_legacy_mapping, 'r') as legacy_mapping_file:
112+
clade_legacy_mapping_dict = yaml.safe_load(legacy_mapping_file)
113+
114+
missing_clades = [VALUE_MISSING_DATA] * len(column_map)
115+
116+
metadata_sorted = sort_tsv_by_column(args.metadata, strain_idx, out_dir)
117+
nextclade_sorted = sort_tsv_by_column(args.nextclade_tsv, seqname_idx, out_dir)
118+
try:
119+
out_fh = open(args.o, 'w', newline='') if out_is_path else args.o
120+
try:
121+
writer = csv.writer(out_fh, delimiter='\t', lineterminator='\n')
122+
writer.writerow(output_header)
123+
124+
with open(metadata_sorted, newline='') as mfh, open(nextclade_sorted, newline='') as nfh:
125+
metadata_rows = csv.reader(mfh, delimiter='\t')
126+
nextclade_rows = csv.reader(nfh, delimiter='\t')
127+
next(metadata_rows) # skip header
128+
next(nextclade_rows) # skip header
129+
130+
# Streaming left merge-join: both inputs sorted by their join key,
131+
# unique keys on each side (transform dedups strain; nextclade is
132+
# tsv-uniq'd on seqName), so it's a 1:1 / 1:0 match with no fan-out.
133+
clade_row = next(nextclade_rows, None)
134+
for metadata_row in metadata_rows:
135+
strain = metadata_row[strain_idx]
136+
while clade_row is not None and clade_row[seqname_idx] < strain:
137+
clade_row = next(nextclade_rows, None)
138+
139+
if clade_row is not None and clade_row[seqname_idx] == strain:
140+
clade_values = [clade_row[i] for i in clade_src_idx]
141+
clade_nextstrain = clade_values[0]
142+
nextstrain_clade = clade_legacy_mapping_dict.get(
143+
clade_nextstrain, f"{clade_nextstrain} (Omicron)")
144+
else:
145+
clade_values = missing_clades
146+
nextstrain_clade = VALUE_MISSING_DATA
147+
148+
metadata_values = [metadata_row[i] for i in metadata_src_idx]
149+
writer.writerow(
150+
[strain]
151+
+ metadata_values[:splice_at] + [nextstrain_clade] + metadata_values[splice_at:]
152+
+ clade_values
153+
)
154+
finally:
155+
if out_is_path:
156+
out_fh.close()
157+
finally:
158+
os.unlink(metadata_sorted)
159+
os.unlink(nextclade_sorted)
140160

141161

142162
if __name__ == '__main__':

workflow/snakemake_rules/nextclade.smk

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ rule generate_metadata:
345345
existing_metadata=f"data/{database}/metadata_transformed.tsv",
346346
clade_legacy_mapping="defaults/clade-legacy-mapping.yml",
347347
output:
348-
metadata=f"data/{database}/metadata.tsv",
348+
metadata=temp(f"data/{database}/metadata_without_clock_deviation.tsv"),
349349
benchmark:
350350
f"benchmarks/generate_metadata_{database}.txt"
351351
shell:
@@ -358,6 +358,21 @@ rule generate_metadata:
358358
"""
359359

360360

361+
rule compute_clock_deviation:
362+
input:
363+
metadata=f"data/{database}/metadata_without_clock_deviation.tsv",
364+
output:
365+
metadata=f"data/{database}/metadata.tsv",
366+
benchmark:
367+
f"benchmarks/compute_clock_deviation_{database}.txt"
368+
shell:
369+
"""
370+
./bin/compute-clock-deviation \
371+
--metadata {input.metadata} \
372+
-o {output.metadata}
373+
"""
374+
375+
361376
rule metadata_version_json:
362377
"""
363378
Generates the metadata version JSON by adding the metadata TSV sha256sum

0 commit comments

Comments
 (0)