Nuntea commited on
Commit
6f3c1ca
·
verified ·
1 Parent(s): 427a8b2

Upload hf_train_1kb.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. hf_train_1kb.py +677 -0
hf_train_1kb.py ADDED
@@ -0,0 +1,677 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ hf_train_1kb.py
4
+ ------------------
5
+ HF Jobs version of modal_pipeline_1kb.py. Combines prepare_data() and
6
+ train_model() into one script, since HF Jobs runs a single command to
7
+ completion rather than exposing persistent, independently-callable
8
+ Functions the way Modal does -- there's no direct equivalent of
9
+ `train_model.remote()` to call repeatedly against a warm environment.
10
+
11
+ All the actual logic (blacklist handling, bedpe parsing with the chr1
12
+ fix, span-fit window placement, anchor-weighted masked loss) is copied
13
+ verbatim from modal_pipeline_1kb.py, which was already tested against
14
+ synthetic data -- this file only changes HOW it's invoked (plain
15
+ function calls + argparse, not Modal decorators) and WHERE data lives
16
+ (a mounted bucket path instead of a Modal Volume path).
17
+
18
+ Expects two mounted volumes (see submit_hf_job.py):
19
+ /code -- read-only dataset repo containing src/ and configs/
20
+ /data -- read-write bucket, plays the role all three Modal volumes
21
+ (hic-data, hic-cache, hic-runs) played: raw/ downloads,
22
+ cache/windows_1kb/ per-window .npz cache, runs/ checkpoints.
23
+
24
+ Run (inside the job, via submit_hf_job.py):
25
+ python hf_train_1kb.py --model bilstm
26
+ python hf_train_1kb.py --model bilstm --exclude-track RAD21
27
+ python hf_train_1kb.py --prepare-only
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import csv
33
+ import gzip
34
+ import hashlib
35
+ import json
36
+ import math
37
+ import random
38
+ import sys
39
+ import time
40
+ from pathlib import Path
41
+
42
+ import numpy as np
43
+ import pandas as pd
44
+ import yaml
45
+
46
+
47
+ def _cfg(config_dir: Path, config_name: str):
48
+ with open(config_dir / config_name) as f:
49
+ return yaml.safe_load(f)
50
+
51
+
52
+ def _track_fingerprint(track_names: list[str]) -> str:
53
+ h = hashlib.sha1("|".join(sorted(track_names)).encode()).hexdigest()
54
+ return h[:8]
55
+
56
+
57
+ # ── Blacklist -- verbatim from modal_pipeline_1kb.py ────────────────────────
58
+
59
+ def load_blacklist(bed_gz_path: Path) -> dict:
60
+ regions: dict = {}
61
+ with gzip.open(bed_gz_path, "rt") as fh:
62
+ for line in fh:
63
+ if line.startswith("#") or line.startswith("track"):
64
+ continue
65
+ p = line.rstrip("\n").split("\t")
66
+ if len(p) < 3:
67
+ continue
68
+ chrom = p[0] if p[0].startswith("chr") else f"chr{p[0]}"
69
+ try:
70
+ start, end = int(p[1]), int(p[2])
71
+ except ValueError:
72
+ continue
73
+ regions.setdefault(chrom, []).append((start, end))
74
+ for chrom in regions:
75
+ regions[chrom].sort()
76
+ return regions
77
+
78
+
79
+ def overlaps_blacklist(chrom: str, start: int, end: int, blacklist: dict) -> bool:
80
+ for (bl_start, bl_end) in blacklist.get(chrom, []):
81
+ if start < bl_end and end > bl_start:
82
+ return True
83
+ return False
84
+
85
+
86
+ # ── Loop parsing -- verbatim, includes the chr1 header-detection fix ───────
87
+
88
+ def parse_encode_bedpe_gz(gz_path) -> list[dict]:
89
+ loops = []
90
+ with gzip.open(gz_path, "rt") as fh:
91
+ for line in fh:
92
+ if line.startswith("#"):
93
+ continue
94
+ p = line.strip().split("\t")
95
+ if len(p) < 6:
96
+ continue
97
+ c1, c2 = p[0], p[3]
98
+ try:
99
+ x1, x2, y1, y2 = int(p[1]), int(p[2]), int(p[4]), int(p[5])
100
+ except ValueError:
101
+ continue
102
+ if c1 != c2:
103
+ continue
104
+ chrom = c1 if c1.startswith("chr") else f"chr{c1}"
105
+ loops.append({"chrom": chrom, "mid_x": (x1 + x2) // 2, "mid_y": (y1 + y2) // 2})
106
+ return loops
107
+
108
+
109
+ # ── Window placement -- verbatim ────────────────────────────────────────────
110
+
111
+ def place_window_loop_centered(loop, window_size, flank_margin, jitter, rng, sizes, blacklist,
112
+ max_attempts: int = 5):
113
+ chrom = loop["chrom"]
114
+ mid_x, mid_y = loop["mid_x"], loop["mid_y"]
115
+ span = abs(mid_y - mid_x)
116
+ max_span = window_size - 2 * flank_margin
117
+ if span > max_span:
118
+ return None, "span_too_large"
119
+ clen = sizes.get(chrom)
120
+ if clen is None:
121
+ return None, "unknown_chrom"
122
+ true_center = (mid_x + mid_y) // 2
123
+ for _ in range(max_attempts):
124
+ center = true_center + rng.randint(-jitter, jitter)
125
+ start = center - window_size // 2
126
+ end = start + window_size
127
+ if start < 0 or end > clen:
128
+ continue
129
+ if not (start + flank_margin <= mid_x <= end - flank_margin):
130
+ continue
131
+ if not (start + flank_margin <= mid_y <= end - flank_margin):
132
+ continue
133
+ if overlaps_blacklist(chrom, start, end, blacklist):
134
+ continue
135
+ return (start, end), None
136
+ return None, "placement_failed"
137
+
138
+
139
+ def sample_background_window(chroms, window_size, sizes, blacklist, rng, max_attempts=20):
140
+ chrom_list = list(chroms)
141
+ for _ in range(max_attempts):
142
+ chrom = rng.choice(chrom_list)
143
+ clen = sizes.get(chrom)
144
+ if clen is None or clen <= window_size:
145
+ continue
146
+ start = rng.randint(0, clen - window_size)
147
+ end = start + window_size
148
+ if overlaps_blacklist(chrom, start, end, blacklist):
149
+ continue
150
+ return chrom, start, end
151
+ return None
152
+
153
+
154
+ # ── Losses -- verbatim ───────────────────────────────────────────────────────
155
+
156
+ def masked_huber_loss(pred, target, valid_mask, delta: float = 0.5):
157
+ import torch
158
+ diff = pred - target
159
+ abs_diff = diff.abs()
160
+ quadratic = torch.clamp(abs_diff, max=delta)
161
+ linear = abs_diff - quadratic
162
+ per_pixel = (0.5 * quadratic ** 2 + delta * linear) * valid_mask
163
+ denom = valid_mask.sum().clamp(min=1.0)
164
+ return per_pixel.sum() / denom
165
+
166
+
167
+ def masked_mse_loss(pred, target, valid_mask):
168
+ per_pixel = (pred - target) ** 2 * valid_mask
169
+ denom = valid_mask.sum().clamp(min=1.0)
170
+ return per_pixel.sum() / denom
171
+
172
+
173
+ def build_anchor_weight_map(n_bins: int, anchor_r, anchor_c, boost_radius: int = 5,
174
+ boost_factor: float = 10.0):
175
+ w = np.ones((n_bins, n_bins), dtype=np.float32)
176
+ if anchor_r is not None and anchor_c is not None and anchor_r >= 0 and anchor_c >= 0:
177
+ r0, r1 = max(0, anchor_r - boost_radius), min(n_bins, anchor_r + boost_radius + 1)
178
+ c0, c1 = max(0, anchor_c - boost_radius), min(n_bins, anchor_c + boost_radius + 1)
179
+ w[r0:r1, c0:c1] = boost_factor
180
+ return w
181
+
182
+
183
+ def masked_weighted_huber_loss(pred, target, valid_mask, weight_map, delta: float = 0.5):
184
+ import torch
185
+ diff = pred - target
186
+ abs_diff = diff.abs()
187
+ quadratic = torch.clamp(abs_diff, max=delta)
188
+ linear = abs_diff - quadratic
189
+ per_pixel = (0.5 * quadratic ** 2 + delta * linear) * valid_mask * weight_map
190
+ denom = (valid_mask * weight_map).sum().clamp(min=1.0)
191
+ return per_pixel.sum() / denom
192
+
193
+
194
+ # ── Data prep ────────────────────────────────────────────────────────────────
195
+
196
+ def resolve_file(rel_path: str, shared_raw, local_raw: Path, url, genomic_io) -> Path:
197
+ """Returns a usable local path for `rel_path`, preferring a file that
198
+ already exists over downloading it.
199
+
200
+ Lookup order:
201
+ 1. shared_raw/rel_path -- the read-only data bucket (e.g. the existing
202
+ 3d-genomics-prediction bucket, which already holds every bigWig and
203
+ both .hic files)
204
+ 2. local_raw/rel_path -- downloaded by an earlier run
205
+ 3. download from `url` into local_raw/rel_path
206
+
207
+ This exists because the first HF Jobs run re-downloaded ~90GB from
208
+ ENCODE into a fresh bucket while identical files sat unused in an
209
+ existing one. Checking before fetching turns that into a no-op.
210
+ Raises if missing everywhere AND no url is given, rather than returning
211
+ a path that doesn't exist and failing later inside pyBigWig/hicstraw
212
+ with a much less obvious message.
213
+ """
214
+ if shared_raw is not None:
215
+ candidate = Path(shared_raw) / rel_path
216
+ if candidate.exists() and candidate.stat().st_size > 0:
217
+ print(f" [shared] {rel_path}")
218
+ return candidate
219
+ candidate = local_raw / rel_path
220
+ if candidate.exists() and candidate.stat().st_size > 0:
221
+ print(f" [cache] {rel_path}")
222
+ return candidate
223
+ if not url:
224
+ raise FileNotFoundError(
225
+ f"'{rel_path}' not found in shared raw dir ({shared_raw}) or local raw dir "
226
+ f"({local_raw}), and no download url was configured for it.")
227
+ genomic_io.download_file(url, candidate)
228
+ return candidate
229
+
230
+
231
+ def prepare_data(cfg: dict, data_dir: Path, genomic_io, genome_constants,
232
+ shared_raw=None) -> None:
233
+ exp = cfg["experiment"]
234
+ raw = data_dir / "raw"
235
+ raw.mkdir(parents=True, exist_ok=True)
236
+ manifest_path = data_dir / "loop_manifest_1kb.csv"
237
+
238
+ track_name_sets = {cl: sorted(cl_cfg["tracks"].keys()) for cl, cl_cfg in cfg["cell_lines"].items()}
239
+ reference_tracks = next(iter(track_name_sets.values()))
240
+ for cl, names in track_name_sets.items():
241
+ if names != reference_tracks:
242
+ raise ValueError(f"Track set mismatch: '{cl}' has {names}, expected {reference_tracks}.")
243
+ track_fp = _track_fingerprint(reference_tracks)
244
+
245
+ fingerprint_path = data_dir / "manifest_fingerprint_1kb.txt"
246
+ needed_fp = (f"{sorted(cfg['cell_lines'].keys())}|{track_fp}|"
247
+ f"{exp['bin_size']}|{exp['window_size']}|{exp['flank_margin']}")
248
+
249
+ blacklist = {}
250
+ if cfg.get("blacklist_filename"):
251
+ bl_path = resolve_file(cfg["blacklist_filename"], shared_raw, raw,
252
+ cfg.get("blacklist_url"), genomic_io)
253
+ blacklist = load_blacklist(bl_path)
254
+ n_regions = sum(len(v) for v in blacklist.values())
255
+ print(f"[prepare] Blacklist: {n_regions} regions across {len(blacklist)} chroms.")
256
+
257
+ for cl_name, cl_cfg in cfg["cell_lines"].items():
258
+ print(f"\n[prepare] {cl_name}: resolving {len(cl_cfg['tracks'])} tracks, loop calls, "
259
+ f"and the intact .hic file...")
260
+ for track_name, entry in cl_cfg["tracks"].items():
261
+ resolve_file(entry["path"], shared_raw, raw, entry.get("url"), genomic_io)
262
+ resolve_file(cl_cfg["loop_filename"], shared_raw, raw, cl_cfg.get("loop_url"), genomic_io)
263
+ # hic_path is config-driven now -- the bucket names .hic files by
264
+ # accession (HepG2/ENCFF807IRK.hic), so the old hardcoded
265
+ # f"{cell_line}.hic" convention doesn't match it. Falls back to that
266
+ # convention when a config doesn't specify hic_path.
267
+ resolve_file(cl_cfg.get("hic_path", f"{cl_name}.hic"), shared_raw, raw,
268
+ cl_cfg.get("hic_url"), genomic_io)
269
+ print(f"[prepare] {cl_name}: done.")
270
+
271
+ if manifest_path.exists():
272
+ old_fp = fingerprint_path.read_text().strip() if fingerprint_path.exists() else None
273
+ if old_fp == needed_fp:
274
+ print(f"\n[prepare] Manifest exists for {needed_fp}. Downloads verified above; "
275
+ f"skipping window rebuild.")
276
+ return
277
+ print(f"\n[prepare] Config changed ({old_fp} -> {needed_fp}). Rebuilding manifest.")
278
+ manifest_path.unlink()
279
+
280
+ sizes = genome_constants.chrom_sizes_hg38()
281
+ bin_size = exp["bin_size"]; window_size = exp["window_size"]
282
+ jitter = exp["jitter"]; flank_margin = exp["flank_margin"]
283
+ n_bins = window_size // bin_size
284
+ train_chroms = set(exp["train_chroms"]); test_chroms = set(exp["test_chroms"])
285
+ rng = random.Random(exp["seed"])
286
+
287
+ rows = []
288
+ n_span_rejected = 0; n_placement_failed = 0
289
+ for cl_name, cl_cfg in cfg["cell_lines"].items():
290
+ loop_file = resolve_file(cl_cfg["loop_filename"], shared_raw, raw,
291
+ cl_cfg.get("loop_url"), genomic_io)
292
+ loops = parse_encode_bedpe_gz(loop_file)
293
+ print(f"\n[prepare] {cl_name}: {len(loops)} intra-chromosomal loops parsed.")
294
+
295
+ for split, chroms, n_target in [
296
+ ("train", train_chroms, exp["train_loops_per_cell_line"]),
297
+ ("test", test_chroms, exp["test_loops_per_cell_line"]),
298
+ ]:
299
+ eligible = [l for l in loops if l["chrom"] in chroms]
300
+ rng.shuffle(eligible)
301
+ placed = 0
302
+ for loop in eligible:
303
+ if placed >= n_target:
304
+ break
305
+ result, reason = place_window_loop_centered(
306
+ loop, window_size, flank_margin, jitter, rng, sizes, blacklist)
307
+ if result is None:
308
+ if reason == "span_too_large":
309
+ n_span_rejected += 1
310
+ else:
311
+ n_placement_failed += 1
312
+ continue
313
+ start, end = result
314
+ rows.append({
315
+ "split": split, "cell_line": cl_name, "window_type": "loop",
316
+ "chrom": loop["chrom"], "start": start, "end": end,
317
+ "bin_size": bin_size, "n_bins": n_bins,
318
+ "loop_mid_x": loop["mid_x"], "loop_mid_y": loop["mid_y"],
319
+ "loop_span_bp": abs(loop["mid_y"] - loop["mid_x"]),
320
+ "track_fingerprint": track_fp,
321
+ })
322
+ placed += 1
323
+ status = "WARNING: only" if placed < n_target else " "
324
+ print(f" {status} {placed}/{n_target} loop windows: {cl_name}/{split}")
325
+
326
+ n_bg_target = exp.get("background_windows_per_cell_line", 0)
327
+ placed_bg = 0
328
+ for _ in range(n_bg_target * 3):
329
+ if placed_bg >= n_bg_target:
330
+ break
331
+ result = sample_background_window(train_chroms, window_size, sizes, blacklist, rng)
332
+ if result is None:
333
+ continue
334
+ chrom, start, end = result
335
+ rows.append({
336
+ "split": "train", "cell_line": cl_name, "window_type": "background",
337
+ "chrom": chrom, "start": start, "end": end,
338
+ "bin_size": bin_size, "n_bins": n_bins,
339
+ "loop_mid_x": "", "loop_mid_y": "", "loop_span_bp": "",
340
+ "track_fingerprint": track_fp,
341
+ })
342
+ placed_bg += 1
343
+ print(f" {placed_bg}/{n_bg_target} background windows: {cl_name}/train")
344
+
345
+ print(f"\n[prepare] Rejected: {n_span_rejected} loops (span too large), "
346
+ f"{n_placement_failed} (blacklist/bounds after retries).")
347
+
348
+ fieldnames = ["split", "cell_line", "window_type", "chrom", "start", "end",
349
+ "bin_size", "n_bins", "loop_mid_x", "loop_mid_y", "loop_span_bp",
350
+ "track_fingerprint"]
351
+ with open(manifest_path, "w", newline="") as fh:
352
+ w = csv.DictWriter(fh, fieldnames=fieldnames)
353
+ w.writeheader(); w.writerows(rows)
354
+ fingerprint_path.write_text(needed_fp)
355
+ print(f"\n[prepare] Done: {len(rows)} windows -> {manifest_path}")
356
+
357
+
358
+ # ── Training ─────────────────────────────────────────────────────────────────
359
+
360
+ def train_model(model_name: str, cfg: dict, data_dir: Path, src_dir: Path,
361
+ exclude_track: str = "", shared_raw=None) -> dict:
362
+ import torch
363
+ from torch.utils.data import DataLoader, Dataset
364
+ sys.path.insert(0, str(src_dir))
365
+ from genomic_io import read_bigwig, _get_hicfile
366
+ from oe_extraction import get_oe_matrix, MAX_OE
367
+
368
+ tr_cfg = cfg["training"]
369
+ raw = data_dir / "raw"
370
+
371
+ def _resolve_existing(rel_path: str) -> str:
372
+ """Same shared-then-local lookup as resolve_file(), minus the
373
+ download fallback -- by training time prepare_data() has already
374
+ ensured every file exists somewhere, so a miss here is a bug worth
375
+ surfacing immediately rather than silently re-fetching 40GB mid-run."""
376
+ if shared_raw is not None:
377
+ cand = Path(shared_raw) / rel_path
378
+ if cand.exists():
379
+ return str(cand)
380
+ return str(raw / rel_path)
381
+
382
+ train_seed = int(tr_cfg.get("seed", cfg.get("experiment", {}).get("seed", 42)))
383
+ torch.manual_seed(train_seed)
384
+ torch.cuda.manual_seed_all(train_seed)
385
+ print(f"[train] seed={train_seed}")
386
+
387
+ track_names = sorted(next(iter(cfg["cell_lines"].values()))["tracks"].keys())
388
+ if exclude_track:
389
+ if exclude_track not in track_names:
390
+ raise ValueError(f"exclude_track='{exclude_track}' not in {track_names}")
391
+ track_names = [t for t in track_names if t != exclude_track]
392
+ print(f"[train] LEAVE-ONE-TRACK-OUT: excluding '{exclude_track}'")
393
+ n_tracks = len(track_names)
394
+ track_fp = _track_fingerprint(track_names)
395
+ max_oe = float(tr_cfg.get("max_oe", MAX_OE))
396
+ print(f"[train] {n_tracks} tracks: {track_names}")
397
+
398
+ class LoopDataset(Dataset):
399
+ def __init__(self, manifest_path, split, normalizer=None, cache_dir=None, clip_pct=99.0):
400
+ full = pd.read_csv(manifest_path)
401
+ self.manifest = full[full["split"] == split].reset_index(drop=True)
402
+ self.normalizer = normalizer; self.clip_pct = clip_pct
403
+ self.cache_root = Path(cache_dir) if cache_dir else None
404
+ if self.cache_root:
405
+ self.cache_root.mkdir(parents=True, exist_ok=True)
406
+
407
+ def __len__(self):
408
+ return len(self.manifest)
409
+
410
+ def _paths(self, cl):
411
+ cl_tracks = cfg["cell_lines"][cl]["tracks"]
412
+ return {name: _resolve_existing(cl_tracks[name]["path"]) for name in track_names} | \
413
+ {"hic": _resolve_existing(cfg["cell_lines"][cl].get("hic_path", f"{cl}.hic"))}
414
+
415
+ def _cache(self, row):
416
+ if self.cache_root is None:
417
+ return None
418
+ bs = int(row["bin_size"])
419
+ return self.cache_root / (f"{row['cell_line']}_{row['chrom']}_{row['start']}_"
420
+ f"{row['end']}_log2oe_v2_{bs}_{track_fp}.npz")
421
+
422
+ def get_tracks(self, idx):
423
+ row = self.manifest.iloc[idx]
424
+ p = self._paths(row["cell_line"]); n = int(row["n_bins"])
425
+ ch, s, e = row["chrom"], int(row["start"]), int(row["end"])
426
+ arrays = [read_bigwig(p[name], ch, s, e, n, self.clip_pct) for name in track_names]
427
+ return np.stack(arrays, axis=0)
428
+
429
+ def __getitem__(self, idx):
430
+ row = self.manifest.iloc[idx]; cl = row["cell_line"]
431
+ ch, s, e = row["chrom"], int(row["start"]), int(row["end"])
432
+ bs = int(row["bin_size"]); cf = self._cache(row)
433
+ if cf is not None and cf.exists():
434
+ data = np.load(cf)
435
+ tracks, log2_oe, valid_mask = data["tracks"], data["log2_oe"], data["valid_mask"]
436
+ else:
437
+ tracks = self.get_tracks(idx)
438
+ hf = _get_hicfile(self._paths(cl)["hic"])
439
+ _, log2_oe, valid_mask = get_oe_matrix(hf, ch, s, e, bs, max_oe=max_oe)
440
+ if cf is not None:
441
+ np.savez_compressed(cf, tracks=tracks.astype(np.float32),
442
+ log2_oe=log2_oe, valid_mask=valid_mask)
443
+ if self.normalizer is not None:
444
+ s_n = self.normalizer[cl]
445
+ tracks = (tracks - s_n["mean"][:, None]) / s_n["std"][:, None]
446
+ if row["window_type"] == "loop" and pd.notna(row.get("loop_mid_x")):
447
+ anchor_r = (int(row["loop_mid_x"]) - s) // bs
448
+ anchor_c = (int(row["loop_mid_y"]) - s) // bs
449
+ else:
450
+ anchor_r = anchor_c = -1
451
+ return {"tracks": torch.from_numpy(tracks).float(),
452
+ "target": torch.from_numpy(log2_oe).float(),
453
+ "valid_mask": torch.from_numpy(valid_mask.astype(np.float32)),
454
+ "anchor_r": anchor_r, "anchor_c": anchor_c, "cell_line": cl}
455
+
456
+ manifest = data_dir / "loop_manifest_1kb.csv"
457
+ run_dir = data_dir / "runs" / f"1kb_{model_name}_{track_fp}"
458
+ run_dir.mkdir(parents=True, exist_ok=True)
459
+ cache_dir = data_dir / "cache" / "windows_1kb"
460
+
461
+ norm_path = data_dir / "runs" / f"normalizer_1kb_{track_fp}.json"
462
+ config_cls = set(cfg["cell_lines"].keys())
463
+ need_refit = True
464
+ if norm_path.exists():
465
+ with open(norm_path) as f:
466
+ normalizer = json.load(f)
467
+ if set(normalizer.keys()) == config_cls:
468
+ need_refit = False
469
+ print(f"[train] Loaded normalizer for {config_cls}.")
470
+
471
+ if need_refit:
472
+ train_df = pd.read_csv(manifest)
473
+ train_df = train_df[train_df["split"] == "train"].reset_index(drop=True)
474
+ raw_ds = LoopDataset(manifest, "train", normalizer=None, cache_dir=cache_dir)
475
+ normalizer = {}
476
+ for cl in train_df["cell_line"].unique():
477
+ cl_pos = train_df[train_df["cell_line"] == cl].index.tolist()
478
+ n_s = min(80, len(cl_pos))
479
+ sample_idx = np.linspace(0, len(cl_pos) - 1, n_s).astype(int)
480
+ sums = np.zeros(n_tracks); sq_sums = np.zeros(n_tracks); count = 0
481
+ for si in sample_idx:
482
+ t = raw_ds.get_tracks(cl_pos[si])
483
+ sums += t.sum(axis=1); sq_sums += (t ** 2).sum(axis=1); count += t.shape[1]
484
+ mean = sums / count
485
+ std = np.sqrt(np.clip(sq_sums / count - mean ** 2, 1e-8, None))
486
+ normalizer[cl] = {"mean": mean.tolist(), "std": std.tolist()}
487
+ print(f" {cl}: " + " ".join(f"{n}={m:.3f}" for n, m in zip(track_names, mean)))
488
+ run_dir.parent.mkdir(parents=True, exist_ok=True)
489
+ with open(norm_path, "w") as f:
490
+ json.dump(normalizer, f, indent=2)
491
+
492
+ norm_np = {cl: {"mean": np.array(v["mean"], dtype=np.float32),
493
+ "std": np.array(v["std"], dtype=np.float32)} for cl, v in normalizer.items()}
494
+
495
+ train_ds = LoopDataset(manifest, "train", normalizer=norm_np, cache_dir=cache_dir)
496
+ test_ds = LoopDataset(manifest, "test", normalizer=norm_np, cache_dir=cache_dir)
497
+ print(f"[train] {len(train_ds)} train, {len(test_ds)} test windows")
498
+
499
+ bs = tr_cfg["batch_size"]
500
+ train_loader = DataLoader(train_ds, batch_size=bs, shuffle=True, num_workers=0, pin_memory=True)
501
+ test_loader = DataLoader(test_ds, batch_size=bs, shuffle=False, num_workers=0, pin_memory=True)
502
+ n_bins = int(train_ds.manifest["n_bins"].iloc[0])
503
+
504
+ device = "cuda"
505
+ if model_name == "cnn":
506
+ from model_cnn import CNNHiCModel
507
+ model = CNNHiCModel(n_tracks=n_tracks, n_bins=n_bins).to(device)
508
+ wd = float(tr_cfg["weight_decay"]); clip_norm = None
509
+ elif model_name == "unet":
510
+ from model_unet import UNetHiCModel
511
+ model = UNetHiCModel(n_tracks=n_tracks, n_bins=n_bins).to(device)
512
+ wd = float(tr_cfg["weight_decay"]); clip_norm = None
513
+ elif model_name == "transformer":
514
+ from model_transformer import TransformerHiCModel
515
+ dropout = float(tr_cfg.get("transformer_dropout", 0.3))
516
+ model = TransformerHiCModel(n_tracks=n_tracks, n_bins=n_bins, dropout=dropout).to(device)
517
+ wd = float(tr_cfg.get("transformer_weight_decay", 1e-3))
518
+ clip_norm = float(tr_cfg.get("transformer_grad_clip_norm", 0.5))
519
+ elif model_name == "bilstm":
520
+ from model_bilstm import BiLSTMHiCModel
521
+ dropout = float(tr_cfg.get("bilstm_dropout", 0.2))
522
+ model = BiLSTMHiCModel(n_tracks=n_tracks, n_bins=n_bins, dropout=dropout).to(device)
523
+ wd = float(tr_cfg.get("bilstm_weight_decay", 1e-5))
524
+ clip_norm = float(tr_cfg.get("bilstm_grad_clip_norm", 1.0))
525
+ else:
526
+ raise ValueError(f"Unknown model: {model_name}")
527
+
528
+ n_params = sum(p.numel() for p in model.parameters())
529
+ print(f"[train] {model_name}: {n_params:,} params")
530
+
531
+ epochs = tr_cfg["epochs"][model_name]
532
+ patience = tr_cfg.get("early_stopping_patience", 10)
533
+ warmup = tr_cfg.get("transformer_warmup_steps", 0) if model_name == "transformer" else 0
534
+ total_steps = len(train_loader) * epochs
535
+ optimizer = torch.optim.Adam(model.parameters(), lr=float(tr_cfg["lr"]), weight_decay=wd)
536
+
537
+ def lr_lambda(step):
538
+ if warmup <= 0:
539
+ return 1.0
540
+ if step < warmup:
541
+ return (step + 1) / warmup
542
+ progress = min(1.0, (step - warmup) / max(1, total_steps - warmup))
543
+ return max(0.05, 0.5 * (1 + math.cos(math.pi * progress)))
544
+
545
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
546
+
547
+ log_path = run_dir / "training_log.csv"
548
+ ckpt_path = run_dir / "best_model.pt"
549
+ best_test_loss = float("inf"); epochs_no_improve = 0; stopped_at = None
550
+ huber_delta = float(tr_cfg.get("huber_delta", 0.5))
551
+ anchor_boost_radius = int(tr_cfg.get(f"{model_name}_anchor_boost_radius",
552
+ tr_cfg.get("anchor_boost_radius", 5)))
553
+ anchor_boost_factor = float(tr_cfg.get(f"{model_name}_anchor_boost_factor",
554
+ tr_cfg.get("anchor_boost_factor", 10.0)))
555
+ print(f"[train] anchor-weighted loss: radius={anchor_boost_radius} factor={anchor_boost_factor}")
556
+
557
+ fields = ["epoch", "train_huber", "train_huber_weighted", "train_mse", "test_huber",
558
+ "test_mse", "epoch_seconds", "lr"]
559
+ with open(log_path, "w", newline="") as log_fh:
560
+ writer = csv.DictWriter(log_fh, fieldnames=fields)
561
+ writer.writeheader()
562
+ for epoch in range(1, epochs + 1):
563
+ t0 = time.time()
564
+ model.train()
565
+ tr_hub = tr_hub_weighted = tr_mse = n_tr = 0; last_lr = None
566
+ for batch in train_loader:
567
+ tracks = batch["tracks"].to(device, non_blocking=True)
568
+ target = batch["target"].to(device, non_blocking=True)
569
+ valid = batch["valid_mask"].to(device, non_blocking=True)
570
+ anchors_r = batch["anchor_r"].numpy(); anchors_c = batch["anchor_c"].numpy()
571
+ weight_maps = np.stack([
572
+ build_anchor_weight_map(n_bins, r if r >= 0 else None, c if c >= 0 else None,
573
+ boost_radius=anchor_boost_radius,
574
+ boost_factor=anchor_boost_factor)
575
+ for r, c in zip(anchors_r, anchors_c)])
576
+ weight = torch.from_numpy(weight_maps).float().to(device, non_blocking=True)
577
+
578
+ optimizer.zero_grad()
579
+ pred = model(tracks)
580
+ loss = masked_weighted_huber_loss(pred, target, valid, weight, delta=huber_delta)
581
+ loss.backward()
582
+ if clip_norm:
583
+ torch.nn.utils.clip_grad_norm_(model.parameters(), clip_norm)
584
+ optimizer.step(); scheduler.step()
585
+ last_lr = scheduler.get_last_lr()[0]
586
+ tr_hub_weighted += loss.item()
587
+ tr_hub += masked_huber_loss(pred, target, valid, delta=huber_delta).item()
588
+ tr_mse += masked_mse_loss(pred, target, valid).item()
589
+ n_tr += 1
590
+ tr_hub /= max(1, n_tr); tr_hub_weighted /= max(1, n_tr); tr_mse /= max(1, n_tr)
591
+
592
+ model.eval(); te_hub = te_mse = n_te = 0
593
+ with torch.no_grad():
594
+ for batch in test_loader:
595
+ tracks = batch["tracks"].to(device, non_blocking=True)
596
+ target = batch["target"].to(device, non_blocking=True)
597
+ valid = batch["valid_mask"].to(device, non_blocking=True)
598
+ pred = model(tracks)
599
+ te_hub += masked_huber_loss(pred, target, valid, delta=huber_delta).item()
600
+ te_mse += masked_mse_loss(pred, target, valid).item()
601
+ n_te += 1
602
+ te_hub /= max(1, n_te); te_mse /= max(1, n_te)
603
+
604
+ dt = time.time() - t0
605
+ print(f" epoch {epoch:3d}/{epochs} tr_hub={tr_hub:.4f} tr_hub_w={tr_hub_weighted:.4f} "
606
+ f"tr_mse={tr_mse:.4f} te_hub={te_hub:.4f} te_mse={te_mse:.4f} "
607
+ f"lr={last_lr:.2e} ({dt:.1f}s)")
608
+ writer.writerow({"epoch": epoch, "train_huber": tr_hub,
609
+ "train_huber_weighted": tr_hub_weighted, "train_mse": tr_mse,
610
+ "test_huber": te_hub, "test_mse": te_mse,
611
+ "epoch_seconds": dt, "lr": last_lr})
612
+ log_fh.flush()
613
+
614
+ if te_hub < best_test_loss:
615
+ best_test_loss = te_hub; epochs_no_improve = 0
616
+ torch.save({"model_name": model_name, "n_bins": n_bins, "n_tracks": n_tracks,
617
+ "track_names": track_names, "epoch": epoch,
618
+ "model_state_dict": model.state_dict(),
619
+ "test_huber": te_hub, "test_mse": te_mse}, ckpt_path)
620
+ else:
621
+ epochs_no_improve += 1
622
+ if epochs_no_improve >= patience:
623
+ stopped_at = epoch
624
+ print(f"[train] Early stop epoch {epoch}")
625
+ break
626
+
627
+ return {"model": model_name, "n_tracks": n_tracks, "best_test_huber": best_test_loss,
628
+ "best_epoch": int(torch.load(ckpt_path, map_location="cpu", weights_only=False)["epoch"]),
629
+ "stopped_early_at": stopped_at}
630
+
631
+
632
+ def main():
633
+ p = argparse.ArgumentParser()
634
+ p.add_argument("--model", default="bilstm", choices=["cnn", "unet", "transformer", "bilstm"])
635
+ p.add_argument("--config-name", default="encode_sources_1kb.yaml")
636
+ p.add_argument("--exclude-track", default="")
637
+ p.add_argument("--prepare-only", action="store_true")
638
+ p.add_argument("--code-dir", default="/code")
639
+ p.add_argument("--data-dir", default="/data")
640
+ p.add_argument("--shared-raw-dir", default="",
641
+ help="Read-only mount holding the already-uploaded raw data "
642
+ "(e.g. /shared for the 3d-genomics-prediction bucket). Files found "
643
+ "here are used as-is; anything missing falls back to downloading "
644
+ "into --data-dir/raw. Empty = download everything, the old behavior.")
645
+ args = p.parse_args()
646
+
647
+ code_dir = Path(args.code_dir)
648
+ data_dir = Path(args.data_dir)
649
+ shared_raw = Path(args.shared_raw_dir) if args.shared_raw_dir else None
650
+ src_dir = code_dir / "src"
651
+ sys.path.insert(0, str(src_dir))
652
+ import genomic_io
653
+ import genome_constants
654
+
655
+ cfg = _cfg(code_dir / "configs", args.config_name)
656
+
657
+ print("=" * 65)
658
+ print(f"HepG2/HCT116 1kb loop prediction (HF Jobs) -- config: {args.config_name}")
659
+ if shared_raw:
660
+ print(f"shared read-only raw data: {shared_raw}")
661
+ print("=" * 65)
662
+ print("\n[Step 1] Preparing data...")
663
+ prepare_data(cfg, data_dir, genomic_io, genome_constants, shared_raw=shared_raw)
664
+ print("[Step 1] Done.")
665
+ if args.prepare_only:
666
+ return
667
+
668
+ print(f"\n[Step 2] Training {args.model}"
669
+ + (f" (excluding {args.exclude_track})" if args.exclude_track else "") + "...")
670
+ r = train_model(args.model, cfg, data_dir, src_dir, args.exclude_track, shared_raw=shared_raw)
671
+ s = f" (early@{r['stopped_early_at']})" if r["stopped_early_at"] else ""
672
+ print(f"\n<- {args.model.upper()}{s}: best_test_huber={r['best_test_huber']:.4f} "
673
+ f"epoch={r['best_epoch']}")
674
+
675
+
676
+ if __name__ == "__main__":
677
+ main()