ci.yml / build (pull_request) Successful in 3m49s
Restructure the benchmark pipeline to direct all simulated data, indices, statistics, and query outputs into a unified `run/` directory. Update Makefile targets, shell scripts, and Python utilities to resolve paths relative to this new base. Adjust documentation and dependency tracking to match the revised layout, and remove outdated temporary artifacts.
186 lines
7.2 KiB
Python
Executable File
186 lines
7.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Compare all reference distance matrices against obikmer phylo outputs.
|
|
|
|
Reads from:
|
|
reference_dist/ — ground-truth matrices computed by build_reference_dist.py
|
|
obikmer_dist/ — matrices produced by `obikmer phylo`
|
|
|
|
Handles label reordering: both matrices are sorted by genome label before
|
|
element-wise comparison, so column/row order differences are irrelevant.
|
|
|
|
Output: stats/dist_comparison/summary.csv
|
|
comparison,max_abs,mean_abs,rmse,n_pairs,status
|
|
"""
|
|
import csv
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
|
|
# ── CSV loading ───────────────────────────────────────────────────────────────
|
|
|
|
def load_matrix(path: Path) -> tuple[list[str], np.ndarray]:
|
|
"""Load a distance-matrix CSV; return (sorted_labels, matrix_float64)."""
|
|
with path.open() as fh:
|
|
reader = csv.reader(fh)
|
|
header = next(reader)[1:] # skip 'genome' column
|
|
raw: dict[str, list[float]] = {}
|
|
for row in reader:
|
|
raw[row[0]] = [float(x) for x in row[1:]]
|
|
|
|
label_to_col = {h: i for i, h in enumerate(header)}
|
|
labels = sorted(raw.keys())
|
|
n = len(labels)
|
|
mat = np.zeros((n, n), dtype=np.float64)
|
|
for i, ri in enumerate(labels):
|
|
for j, cj in enumerate(labels):
|
|
mat[i, j] = raw[ri][label_to_col[cj]]
|
|
return labels, mat
|
|
|
|
|
|
# ── comparison ────────────────────────────────────────────────────────────────
|
|
|
|
def compare(label: str,
|
|
ref_path: Path,
|
|
obi_path: Path,
|
|
tol: float = 1e-4) -> dict:
|
|
if not ref_path.exists():
|
|
return {'comparison': label, 'status': 'REF_MISSING',
|
|
'max_abs': '', 'mean_abs': '', 'rmse': '', 'n_pairs': ''}
|
|
if not obi_path.exists():
|
|
return {'comparison': label, 'status': 'OBI_MISSING',
|
|
'max_abs': '', 'mean_abs': '', 'rmse': '', 'n_pairs': ''}
|
|
|
|
ref_labels, ref_mat = load_matrix(ref_path)
|
|
obi_labels, obi_mat = load_matrix(obi_path)
|
|
|
|
if ref_labels != obi_labels:
|
|
only_ref = sorted(set(ref_labels) - set(obi_labels))
|
|
only_obi = sorted(set(obi_labels) - set(ref_labels))
|
|
print(f' [{label}] label mismatch — '
|
|
f'only_ref={only_ref} only_obi={only_obi}', file=sys.stderr)
|
|
return {'comparison': label, 'status': 'LABEL_MISMATCH',
|
|
'max_abs': '', 'mean_abs': '', 'rmse': '', 'n_pairs': ''}
|
|
|
|
n = len(ref_labels)
|
|
# Off-diagonal mask
|
|
mask = ~np.eye(n, dtype=bool)
|
|
diff = np.abs(ref_mat[mask] - obi_mat[mask])
|
|
n_pairs = diff.size
|
|
|
|
max_abs = float(diff.max())
|
|
mean_abs = float(diff.mean())
|
|
rmse = float(np.sqrt((diff ** 2).mean()))
|
|
status = 'PASS' if max_abs <= tol else 'FAIL'
|
|
|
|
print(f' [{label}] n={n_pairs} '
|
|
f'max={max_abs:.3e} mean={mean_abs:.3e} rmse={rmse:.3e} {status}',
|
|
file=sys.stderr)
|
|
return {
|
|
'comparison': label,
|
|
'max_abs': f'{max_abs:.6e}',
|
|
'mean_abs': f'{mean_abs:.6e}',
|
|
'rmse': f'{rmse:.6e}',
|
|
'n_pairs': str(n_pairs),
|
|
'status': status,
|
|
}
|
|
|
|
|
|
# ── comparison table ──────────────────────────────────────────────────────────
|
|
|
|
# (label, ref_csv, obikmer_csv)
|
|
# The reference jaccard/shared is presence-based, which should match both
|
|
# presence/jaccard and count/jaccard (threshold=1).
|
|
COMPARISONS = [
|
|
# ── presence index ────────────────────────────────────────────────────────
|
|
('presence/jaccard_dist',
|
|
'reference_dist/jaccard_dist.csv',
|
|
'obikmer_dist/presence/jaccard_dist.csv'),
|
|
|
|
('presence/jaccard_shared',
|
|
'reference_dist/shared_kmers.csv',
|
|
'obikmer_dist/presence/jaccard_shared.csv'),
|
|
|
|
('presence/hamming_dist',
|
|
'reference_dist/hamming_dist.csv',
|
|
'obikmer_dist/presence/hamming_dist.csv'),
|
|
|
|
# ── count index (jaccard cross-check) ─────────────────────────────────────
|
|
('count/jaccard_dist',
|
|
'reference_dist/jaccard_dist.csv',
|
|
'obikmer_dist/count/jaccard_dist.csv'),
|
|
|
|
('count/jaccard_shared',
|
|
'reference_dist/shared_kmers.csv',
|
|
'obikmer_dist/count/jaccard_shared.csv'),
|
|
|
|
# ── count index (count-based metrics) ────────────────────────────────────
|
|
('count/bray_curtis_dist',
|
|
'reference_dist/bray_curtis_dist.csv',
|
|
'obikmer_dist/count/bray_curtis_dist.csv'),
|
|
|
|
('count/relfreq_bray_curtis_dist',
|
|
'reference_dist/relfreq_bray_curtis_dist.csv',
|
|
'obikmer_dist/count/relfreq_bray_curtis_dist.csv'),
|
|
|
|
('count/euclidean_dist',
|
|
'reference_dist/euclidean_dist.csv',
|
|
'obikmer_dist/count/euclidean_dist.csv'),
|
|
|
|
('count/relfreq_euclidean_dist',
|
|
'reference_dist/relfreq_euclidean_dist.csv',
|
|
'obikmer_dist/count/relfreq_euclidean_dist.csv'),
|
|
|
|
('count/hellinger_dist',
|
|
'reference_dist/hellinger_dist.csv',
|
|
'obikmer_dist/count/hellinger_dist.csv'),
|
|
|
|
('count/hellinger_euclidean_dist',
|
|
'reference_dist/hellinger_euclidean_dist.csv',
|
|
'obikmer_dist/count/hellinger_euclidean_dist.csv'),
|
|
]
|
|
|
|
|
|
# ── main ─────────────────────────────────────────────────────────────────────
|
|
|
|
def main() -> None:
|
|
import argparse
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument('--tol', type=float, default=1e-4,
|
|
help='Max abs diff threshold for PASS/FAIL (default 1e-4)')
|
|
ap.add_argument('--run-dir', default='run',
|
|
help='Root directory holding reference_dist/ and obikmer_dist/ (default: run)')
|
|
ap.add_argument('--out', default='run/stats/dist_comparison/summary.csv',
|
|
help='Output summary CSV path')
|
|
args = ap.parse_args()
|
|
|
|
out_path = Path(args.out)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
run_dir = Path(args.run_dir)
|
|
print(f'Comparing {len(COMPARISONS)} matrix pairs…', file=sys.stderr)
|
|
rows = []
|
|
for label, ref, obi in COMPARISONS:
|
|
rows.append(compare(label, run_dir / ref, run_dir / obi, tol=args.tol))
|
|
|
|
fields = ['comparison', 'max_abs', 'mean_abs', 'rmse', 'n_pairs', 'status']
|
|
with out_path.open('w', newline='') as fh:
|
|
w = csv.DictWriter(fh, fieldnames=fields)
|
|
w.writeheader()
|
|
w.writerows(rows)
|
|
|
|
print(f'\n→ {out_path}', file=sys.stderr)
|
|
|
|
n_fail = sum(1 for r in rows if r.get('status') == 'FAIL')
|
|
n_pass = sum(1 for r in rows if r.get('status') == 'PASS')
|
|
print(f'Summary: {n_pass} PASS {n_fail} FAIL '
|
|
f'{len(rows) - n_pass - n_fail} SKIP', file=sys.stderr)
|
|
if n_fail:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|