zipoop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zipoop/__init__.py +11 -0
- zipoop/cli.py +30 -0
- zipoop/data/encode-panel_1885.json +32502 -0
- zipoop/dataset.py +119 -0
- zipoop/decoder.py +62 -0
- zipoop/evaluation.py +77 -0
- zipoop/masks.py +73 -0
- zipoop/network.py +102 -0
- zipoop/panel.py +26 -0
- zipoop/shard.py +105 -0
- zipoop/teacher.py +43 -0
- zipoop-0.1.0.dist-info/METADATA +51 -0
- zipoop-0.1.0.dist-info/RECORD +17 -0
- zipoop-0.1.0.dist-info/WHEEL +5 -0
- zipoop-0.1.0.dist-info/entry_points.txt +2 -0
- zipoop-0.1.0.dist-info/licenses/LICENSE +21 -0
- zipoop-0.1.0.dist-info/top_level.txt +1 -0
zipoop/dataset.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The merged shard dataset: ``<root>/<chrom>.npz`` with members "<window>/<key>", plus ``MANIFEST.tsv``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import torch
|
|
13
|
+
|
|
14
|
+
from .shard import Shard, ShardView, read_shard
|
|
15
|
+
from .teacher import TeacherWindow, load_window
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def chrom_of(window: str) -> str:
|
|
19
|
+
return window.rsplit("_", 2)[0]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Dataset:
|
|
23
|
+
"""Opening a chromosome parses its whole zip directory (chr1: about 3.6 s and 0.9 GB), so handles are cached
|
|
24
|
+
per chromosome; forked DataLoader workers notice the new pid and reopen their own."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, root):
|
|
27
|
+
self.root = Path(root)
|
|
28
|
+
if not self.root.is_dir():
|
|
29
|
+
raise FileNotFoundError(self.root)
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._pid = os.getpid()
|
|
32
|
+
self._open = {} # chrom -> (NpzFile, {window: [member keys]})
|
|
33
|
+
|
|
34
|
+
def __getstate__(self):
|
|
35
|
+
return {"root": self.root}
|
|
36
|
+
|
|
37
|
+
def __setstate__(self, state):
|
|
38
|
+
self.__init__(state["root"])
|
|
39
|
+
|
|
40
|
+
def chroms(self) -> list[str]:
|
|
41
|
+
return sorted(p.stem for p in self.root.glob("*.npz"))
|
|
42
|
+
|
|
43
|
+
def _chrom(self, chrom):
|
|
44
|
+
with self._lock:
|
|
45
|
+
if self._pid != os.getpid():
|
|
46
|
+
self._pid, self._open = os.getpid(), {}
|
|
47
|
+
if chrom not in self._open:
|
|
48
|
+
path = self.root / f"{chrom}.npz"
|
|
49
|
+
if not path.exists():
|
|
50
|
+
raise FileNotFoundError(path)
|
|
51
|
+
npz = np.load(path, allow_pickle=False)
|
|
52
|
+
index = {}
|
|
53
|
+
for name in npz.files:
|
|
54
|
+
window, key = name.split("/", 1)
|
|
55
|
+
index.setdefault(window, []).append(key)
|
|
56
|
+
self._open[chrom] = (npz, index)
|
|
57
|
+
return self._open[chrom]
|
|
58
|
+
|
|
59
|
+
def windows(self, chrom: str) -> list[str]:
|
|
60
|
+
"""Window names of one chromosome in genome order."""
|
|
61
|
+
return list(self._chrom(chrom)[1])
|
|
62
|
+
|
|
63
|
+
def read(self, window: str) -> Shard:
|
|
64
|
+
npz, index = self._chrom(chrom_of(window))
|
|
65
|
+
if window not in index:
|
|
66
|
+
raise KeyError(window)
|
|
67
|
+
return read_shard(ShardView(npz, window, index[window]))
|
|
68
|
+
|
|
69
|
+
def load_window(self, window: str, device="cpu", zero=None) -> TeacherWindow:
|
|
70
|
+
return load_window(self.read(window), device, zero)
|
|
71
|
+
|
|
72
|
+
def load_region(self, windows, device="cpu", zero=None) -> TeacherWindow:
|
|
73
|
+
"""Adjacent windows, in the given order, concatenated along the position axis."""
|
|
74
|
+
ws = [self.load_window(w, device, zero) for w in windows]
|
|
75
|
+
return TeacherWindow("+".join(w.key for w in ws), *(torch.cat([getattr(w, f) for w in ws])
|
|
76
|
+
for f in ("dna", "signal", "valid")))
|
|
77
|
+
|
|
78
|
+
def close(self):
|
|
79
|
+
with self._lock:
|
|
80
|
+
for npz, _ in self._open.values():
|
|
81
|
+
npz.close()
|
|
82
|
+
self._open.clear()
|
|
83
|
+
|
|
84
|
+
def __enter__(self):
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *exc):
|
|
88
|
+
self.close()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_file(root: Path, row: dict) -> str | None:
|
|
92
|
+
path = root / f"{row['chrom']}.npz"
|
|
93
|
+
if not path.exists():
|
|
94
|
+
return "missing"
|
|
95
|
+
if path.stat().st_size != int(row["bytes"]):
|
|
96
|
+
return f"size {path.stat().st_size} != {row['bytes']}"
|
|
97
|
+
h = hashlib.sha256()
|
|
98
|
+
with open(path, "rb") as f:
|
|
99
|
+
for block in iter(lambda: f.read(1 << 24), b""):
|
|
100
|
+
h.update(block)
|
|
101
|
+
return None if h.hexdigest() == row["sha256"] else "sha256 mismatch"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def verify(root, chroms=None, progress=None, workers=4) -> list[tuple[str, str]]:
|
|
105
|
+
"""Check every chromosome file against MANIFEST.tsv (size, then sha256). Returns [(chrom, problem)]."""
|
|
106
|
+
root = Path(root)
|
|
107
|
+
manifest = root / "MANIFEST.tsv"
|
|
108
|
+
if not manifest.exists():
|
|
109
|
+
raise FileNotFoundError(manifest)
|
|
110
|
+
rows = [r for r in csv.DictReader(open(manifest, encoding="utf-8"), delimiter="\t")
|
|
111
|
+
if not chroms or r["chrom"] in chroms]
|
|
112
|
+
problems = []
|
|
113
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
114
|
+
for row, problem in zip(rows, pool.map(lambda r: _check_file(root, r), rows)):
|
|
115
|
+
if problem:
|
|
116
|
+
problems.append((row["chrom"], problem))
|
|
117
|
+
if progress:
|
|
118
|
+
progress(row["chrom"], problem)
|
|
119
|
+
return problems
|
zipoop/decoder.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Teacher decoder: sparse-FFT base signal + DNA-conditioned network residual."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import torch
|
|
6
|
+
|
|
7
|
+
from .network import TeacherNet
|
|
8
|
+
from .shard import Shard
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _unscale(x: torch.Tensor, mean: torch.Tensor, power: torch.Tensor) -> torch.Tensor:
|
|
12
|
+
magnitude = x.abs()
|
|
13
|
+
magnitude = torch.where(magnitude > 10, (magnitude + 10).square() / 40, magnitude)
|
|
14
|
+
return x.sign() * magnitude.pow(1 / power) * mean
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Decoder:
|
|
18
|
+
def __init__(self, shard: Shard, device="cpu"):
|
|
19
|
+
self.shard = shard
|
|
20
|
+
self.device = torch.device(device)
|
|
21
|
+
self.shape = shard.shape
|
|
22
|
+
self.valid = shard.valid
|
|
23
|
+
self.net = TeacherNet(shard.config, shard.shape[1]).to(self.device)
|
|
24
|
+
if set(shard.parameters) != set(self.net.state_dict()):
|
|
25
|
+
raise ValueError("Decoder parameters are incomplete or incompatible")
|
|
26
|
+
self.net.load_state_dict({k: torch.from_numpy(v).to(self.device) for k, v in shard.parameters.items()},
|
|
27
|
+
strict=True)
|
|
28
|
+
self.net.eval()
|
|
29
|
+
t = lambda a: torch.from_numpy(a).float().to(self.device) # noqa: E731
|
|
30
|
+
self._scales = tuple(t(getattr(shard, n)) for n in ("mean", "power", "gain", "offset"))
|
|
31
|
+
self._final = (t(shard.final_gain), t(shard.final_offset))
|
|
32
|
+
|
|
33
|
+
@torch.no_grad()
|
|
34
|
+
def decode(self, start=0, end=None, tracks=None, missing_nan=True) -> torch.Tensor:
|
|
35
|
+
"""(end - start, len(tracks)) float32. Positions the mask marks missing are NaN unless ``missing_nan`` is
|
|
36
|
+
False (then the raw network output, which training never constrained there, is returned)."""
|
|
37
|
+
length, count = self.shape
|
|
38
|
+
end = length if end is None else end
|
|
39
|
+
if not 0 <= start < end <= length:
|
|
40
|
+
raise ValueError("Invalid position slice")
|
|
41
|
+
ids = np.arange(count, dtype=np.int64) if tracks is None else np.asarray(tracks)
|
|
42
|
+
if ids.ndim != 1 or not np.issubdtype(ids.dtype, np.integer) or np.any((ids < 0) | (ids >= count)):
|
|
43
|
+
raise ValueError("Invalid track selection")
|
|
44
|
+
out = self._fft(ids, start, end) + self._residual(ids, start, end)
|
|
45
|
+
out = out * self._final[0][ids] + self._final[1][ids]
|
|
46
|
+
if missing_nan:
|
|
47
|
+
out = out.masked_fill(~torch.from_numpy(self.valid[start:end][:, ids]).to(self.device), float("nan"))
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
def _fft(self, ids, start, end):
|
|
51
|
+
s = self.shard
|
|
52
|
+
spectrum = torch.zeros((len(ids), self.shape[0] // 2 + 1), dtype=torch.complex64, device=self.device)
|
|
53
|
+
spectrum.scatter_(1, torch.from_numpy(s.fft_indices[ids].astype(np.int64)).to(self.device),
|
|
54
|
+
torch.from_numpy(s.fft_coefficients[ids]).to(self.device))
|
|
55
|
+
return torch.fft.irfft(spectrum, n=self.shape[0], dim=1)[:, start:end].T
|
|
56
|
+
|
|
57
|
+
def _residual(self, ids, start, end):
|
|
58
|
+
mean, power, gain, offset = self._scales
|
|
59
|
+
idx = torch.as_tensor(ids, device=self.device)
|
|
60
|
+
dna = torch.from_numpy(self.shard.dna.astype(np.int64)).unsqueeze(0).to(self.device)
|
|
61
|
+
pred = self.net.head(self.net.features(dna)[0, start:end], idx)
|
|
62
|
+
return _unscale(pred, mean[idx], power[idx]) * gain[idx] + offset[idx]
|
zipoop/evaluation.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Evaluation convention: per 32 kb window, per track, 1 bp Pearson, then the mean over evaluable
|
|
2
|
+
(window, track) pairs. Never concatenate windows into one Pearson: between-window level differences
|
|
3
|
+
would leak into the correlation, inflating it with region length and making lengths incomparable."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
from .panel import panel_tracks, zero_tracks
|
|
10
|
+
|
|
11
|
+
WINDOW = 32768
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def per_track_pearson(pred, truth, mask):
|
|
15
|
+
"""Pearson over observed positions per column; second value marks evaluable columns (constant targets are not)."""
|
|
16
|
+
counts = mask.sum(0).clamp_min(1)
|
|
17
|
+
a = (pred - (pred * mask).sum(0) / counts) * mask
|
|
18
|
+
b = (truth - (truth * mask).sum(0) / counts) * mask
|
|
19
|
+
var_a, var_b = a.square().sum(0), b.square().sum(0)
|
|
20
|
+
return (a * b).sum(0) / (var_a * var_b).clamp_min(1e-24).sqrt(), (counts >= 2) & (var_b > 1e-12)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def calibrate(pred, truth, valid):
|
|
24
|
+
"""Per-track affine map of a Pearson-trained output into truth's units (Pearson unchanged)."""
|
|
25
|
+
counts = valid.sum(0).clamp_min(1)
|
|
26
|
+
pred_mean = (pred * valid).sum(0) / counts
|
|
27
|
+
truth_mean = (truth * valid).sum(0) / counts
|
|
28
|
+
a, b = (pred - pred_mean) * valid, (truth - truth_mean) * valid
|
|
29
|
+
variance = a.square().sum(0)
|
|
30
|
+
gain = ((a * b).sum(0) / variance.clamp_min(1e-12)).clamp_min(0)
|
|
31
|
+
gain = torch.where(variance > 1e-12, gain, torch.zeros_like(gain))
|
|
32
|
+
return pred * gain + (truth_mean - gain * pred_mean)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def restore_real(signal, valid, zero=None):
|
|
36
|
+
"""Apply the teacher's missing-value rule to real data; tracks on the last axis, numpy or torch."""
|
|
37
|
+
zero = zero_tracks() if zero is None else np.asarray(zero, dtype=bool)
|
|
38
|
+
if torch.is_tensor(signal):
|
|
39
|
+
zero = torch.as_tensor(zero, device=signal.device)
|
|
40
|
+
return torch.where(valid, signal, torch.zeros_like(signal)), valid | zero
|
|
41
|
+
return np.where(valid, signal, 0).astype(signal.dtype, copy=False), valid | zero
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def windowed(x, window=WINDOW):
|
|
45
|
+
"""(L, C) -> (window, (L // window) * C): one column per (window, track), so column Pearson is per-window."""
|
|
46
|
+
L, C = x.shape
|
|
47
|
+
if L % window:
|
|
48
|
+
raise ValueError(f"length {L} is not a multiple of window {window}")
|
|
49
|
+
return x.reshape(L // window, window, C).permute(1, 0, 2).reshape(window, (L // window) * C)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def window_pearson(pred, target, valid, window=WINDOW, chunk=256):
|
|
53
|
+
"""(L // window, T) Pearson, NaN where not evaluable. Inputs are (L, T) with L a multiple of ``window``."""
|
|
54
|
+
L, T = target.shape
|
|
55
|
+
cols = []
|
|
56
|
+
for c in range(0, T, chunk):
|
|
57
|
+
d = slice(c, min(T, c + chunk))
|
|
58
|
+
p, ok = per_track_pearson(windowed(pred[:, d].float(), window), windowed(target[:, d].float(), window),
|
|
59
|
+
windowed(valid[:, d], window))
|
|
60
|
+
cols.append(torch.where(ok, p, torch.full_like(p, float("nan"))).reshape(L // window, -1))
|
|
61
|
+
return torch.cat(cols, 1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def summarize(p, tracks=None):
|
|
65
|
+
"""Mean/median over evaluable pairs, plus per-modality and per-window means."""
|
|
66
|
+
tracks = panel_tracks() if tracks is None else tracks
|
|
67
|
+
p = p.detach().float().cpu().numpy()
|
|
68
|
+
modality = np.array([t.get("modality", "?") for t in tracks])
|
|
69
|
+
ok = np.isfinite(p)
|
|
70
|
+
out = {"mean": float(p[ok].mean()) if ok.any() else None, "median": float(np.median(p[ok])) if ok.any() else None,
|
|
71
|
+
"pairs": int(ok.sum()), "by_modality": {},
|
|
72
|
+
"per_window": [float(np.nanmean(r)) if np.isfinite(r).any() else None for r in p]}
|
|
73
|
+
for m in sorted(set(modality)):
|
|
74
|
+
sel = ok & (modality == m)[None, :]
|
|
75
|
+
if sel.any():
|
|
76
|
+
out["by_modality"][m] = {"mean": float(p[sel].mean()), "pairs": int(sel.sum())}
|
|
77
|
+
return out
|
zipoop/masks.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Decoder for the validity-mask wire format ``track-rle-lzma-v2``.
|
|
2
|
+
|
|
3
|
+
A mask is a (length, tracks) boolean field. Tracks are classified as all-absent, all-present or mixed;
|
|
4
|
+
only mixed tracks carry data, as concatenated run lengths (uvarint, first byte = value of the first run)
|
|
5
|
+
inside one LZMA stream.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import lzma
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
MASK_FORMAT = "track-rle-lzma-v2"
|
|
14
|
+
_MAX_CELLS = 1_000_000_000
|
|
15
|
+
_LZMA_MEMLIMIT = 128 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _uvarint(data: memoryview, pos: int) -> tuple[int, int]:
|
|
19
|
+
value = shift = 0
|
|
20
|
+
while True:
|
|
21
|
+
if pos >= len(data) or shift >= 64:
|
|
22
|
+
raise ValueError("Invalid or truncated varint")
|
|
23
|
+
byte = data[pos]
|
|
24
|
+
pos += 1
|
|
25
|
+
value |= (byte & 0x7F) << shift
|
|
26
|
+
if not byte & 0x80:
|
|
27
|
+
return value, pos
|
|
28
|
+
shift += 7
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _bit_plane(data: memoryview, pos: int, count: int) -> tuple[np.ndarray, int]:
|
|
32
|
+
nbytes = (count + 7) // 8
|
|
33
|
+
bits = np.unpackbits(np.frombuffer(data[pos:pos + nbytes], dtype=np.uint8), bitorder="little", count=count)
|
|
34
|
+
return bits.astype(bool), pos + nbytes
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _run_column(data: memoryview, pos: int, length: int) -> tuple[np.ndarray, int]:
|
|
38
|
+
value, pos = _uvarint(data, pos)
|
|
39
|
+
if value not in (0, 1):
|
|
40
|
+
raise ValueError("Invalid run value")
|
|
41
|
+
runs, total = [], 0
|
|
42
|
+
while total < length:
|
|
43
|
+
run, pos = _uvarint(data, pos)
|
|
44
|
+
if run <= 0 or run > length - total:
|
|
45
|
+
raise ValueError("Invalid run length")
|
|
46
|
+
runs.append(run)
|
|
47
|
+
total += run
|
|
48
|
+
values = (np.arange(len(runs)) % 2 == 0) == bool(value)
|
|
49
|
+
return np.repeat(values, runs), pos
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def decode_mask(data: bytes, shape: tuple[int, int]) -> np.ndarray:
|
|
53
|
+
length, tracks = (int(n) for n in shape)
|
|
54
|
+
if min(length, tracks) <= 0 or length * tracks > _MAX_CELLS:
|
|
55
|
+
raise ValueError("Invalid mask shape")
|
|
56
|
+
view = memoryview(data)
|
|
57
|
+
count, pos = _uvarint(view, 0)
|
|
58
|
+
if count != tracks:
|
|
59
|
+
raise ValueError("class vector length mismatch")
|
|
60
|
+
mixed, pos = _bit_plane(view, pos, count)
|
|
61
|
+
present, pos = _bit_plane(view, pos, count)
|
|
62
|
+
mask = np.zeros((length, tracks), dtype=bool)
|
|
63
|
+
mask[:, ~mixed & present] = True
|
|
64
|
+
lz = lzma.LZMADecompressor(memlimit=_LZMA_MEMLIMIT)
|
|
65
|
+
payload = memoryview(lz.decompress(data[pos:], max_length=length * tracks * 2 + 1024))
|
|
66
|
+
if not lz.eof or lz.unused_data:
|
|
67
|
+
raise ValueError("Oversized or trailing mask payload")
|
|
68
|
+
cursor = 0
|
|
69
|
+
for track in np.flatnonzero(mixed):
|
|
70
|
+
mask[:, track], cursor = _run_column(payload, cursor, length)
|
|
71
|
+
if cursor != len(payload):
|
|
72
|
+
raise ValueError("Trailing mask runs")
|
|
73
|
+
return mask
|
zipoop/network.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Inference-only teacher network: DNA -> per-base rank features -> per-track head."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
from torch import nn
|
|
9
|
+
from torch.nn import functional as F
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ModelConfig:
|
|
14
|
+
width: int
|
|
15
|
+
rank: int
|
|
16
|
+
transformer_layers: int
|
|
17
|
+
attention_heads: int
|
|
18
|
+
downsample_levels: int
|
|
19
|
+
|
|
20
|
+
# The only variant present in Zipoop shards; anything else is rejected rather than silently mis-decoded.
|
|
21
|
+
# Older archives that omit "pooling" used average pooling, so a missing key must not count as "max".
|
|
22
|
+
_SUPPORTED = {"pooling": "max", "position_encoding": "sinusoidal", "pair_channels": 0, "head_nonlinear_rank": 0}
|
|
23
|
+
_LEGACY_DEFAULT = {"pooling": "avg", "position_encoding": "sinusoidal", "pair_channels": 0, "head_nonlinear_rank": 0}
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_json(cls, raw: dict) -> "ModelConfig":
|
|
27
|
+
for key, expected in cls._SUPPORTED.items():
|
|
28
|
+
got = raw.get(key, cls._LEGACY_DEFAULT[key])
|
|
29
|
+
if got != expected:
|
|
30
|
+
raise ValueError(f"Unsupported model option {key}={got!r} (zipoop decodes {expected!r} only)")
|
|
31
|
+
cfg = cls(*(int(raw[name]) for name in ("width", "rank", "transformer_layers", "attention_heads",
|
|
32
|
+
"downsample_levels")))
|
|
33
|
+
if min(cfg.width, cfg.rank, cfg.transformer_layers, cfg.attention_heads) < 1:
|
|
34
|
+
raise ValueError("Model dimensions must be positive")
|
|
35
|
+
if cfg.width % cfg.attention_heads or not 1 <= cfg.downsample_levels <= 7:
|
|
36
|
+
raise ValueError("Width must divide attention heads; levels must be 1..7")
|
|
37
|
+
return cfg
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Residual(nn.Module):
|
|
41
|
+
def __init__(self, channels: int):
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.net = nn.Sequential(nn.GroupNorm(1, channels), nn.GELU(),
|
|
44
|
+
nn.Conv1d(channels, channels, 5, padding=2),
|
|
45
|
+
nn.GroupNorm(1, channels), nn.GELU(),
|
|
46
|
+
nn.Conv1d(channels, channels, 1))
|
|
47
|
+
|
|
48
|
+
def forward(self, x):
|
|
49
|
+
return x + self.net(x)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _TrackHead(nn.Module):
|
|
53
|
+
def __init__(self, rank: int, tracks: int):
|
|
54
|
+
super().__init__()
|
|
55
|
+
self.weight = nn.Parameter(torch.empty(tracks, rank))
|
|
56
|
+
self.bias = nn.Parameter(torch.empty(tracks))
|
|
57
|
+
self.scale_logits = nn.Parameter(torch.empty(tracks))
|
|
58
|
+
self.register_buffer("signed", torch.zeros(tracks, dtype=torch.bool))
|
|
59
|
+
|
|
60
|
+
def forward(self, z, tracks):
|
|
61
|
+
x = F.linear(z, self.weight[tracks], self.bias[tracks])
|
|
62
|
+
return torch.where(self.signed[tracks], x, F.softplus(x)) * F.softplus(self.scale_logits[tracks])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sinusoidal(length: int, width: int, like: torch.Tensor) -> torch.Tensor:
|
|
66
|
+
p = torch.arange(length, device=like.device, dtype=like.dtype)[:, None]
|
|
67
|
+
freq = torch.exp(torch.arange(0, width, 2, device=like.device, dtype=like.dtype) * (-math.log(10000) / width))
|
|
68
|
+
table = torch.zeros(length, width, device=like.device, dtype=like.dtype)
|
|
69
|
+
table[:, 0::2] = torch.sin(p * freq)
|
|
70
|
+
table[:, 1::2] = torch.cos(p * freq[:table[:, 1::2].shape[1]])
|
|
71
|
+
return table
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TeacherNet(nn.Module):
|
|
75
|
+
"""Parameter names match the stored shards (stem, down, attention, up, trunk_projection, head)."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, config: ModelConfig, tracks: int):
|
|
78
|
+
super().__init__()
|
|
79
|
+
c = config.width
|
|
80
|
+
self.config = config
|
|
81
|
+
self.stem = nn.Conv1d(4, c, 15, padding=7)
|
|
82
|
+
self.down = nn.ModuleList(_Residual(c) for _ in range(config.downsample_levels))
|
|
83
|
+
layer = nn.TransformerEncoderLayer(c, config.attention_heads, c * 4, dropout=0, activation="gelu",
|
|
84
|
+
batch_first=True, norm_first=True)
|
|
85
|
+
self.attention = nn.TransformerEncoder(layer, config.transformer_layers, enable_nested_tensor=False)
|
|
86
|
+
self.up = nn.ModuleList(_Residual(c) for _ in range(config.downsample_levels))
|
|
87
|
+
self.trunk_projection = nn.Conv1d(c, config.rank, 1)
|
|
88
|
+
self.head = _TrackHead(config.rank, tracks)
|
|
89
|
+
|
|
90
|
+
def features(self, dna: torch.Tensor) -> torch.Tensor:
|
|
91
|
+
"""(B, L) int bases (0-3 = ACGT, 4 = N -> all-zero input) -> (B, L, rank)."""
|
|
92
|
+
x = self.stem(F.one_hot(dna.long(), 5)[..., :4].float().transpose(1, 2))
|
|
93
|
+
skips = []
|
|
94
|
+
for block in self.down:
|
|
95
|
+
x = block(x)
|
|
96
|
+
skips.append(x)
|
|
97
|
+
x = F.max_pool1d(x, 2)
|
|
98
|
+
x = x.transpose(1, 2)
|
|
99
|
+
x = self.attention(x + _sinusoidal(x.shape[1], x.shape[2], x)).transpose(1, 2)
|
|
100
|
+
for block, skip in zip(self.up, reversed(skips)):
|
|
101
|
+
x = block(F.interpolate(x, size=skip.shape[-1], mode="nearest") + skip)
|
|
102
|
+
return self.trunk_projection(x).transpose(1, 2)
|
zipoop/panel.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The 1,885-track ENCODE panel the shards were fitted to (track order = last axis of every signal array)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
PANEL = Path(__file__).resolve().parent / "data" / "encode-panel_1885.json"
|
|
11
|
+
ZERO_MODALITIES = frozenset({"rna"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@lru_cache(maxsize=None)
|
|
15
|
+
def _default_tracks():
|
|
16
|
+
return json.load(open(PANEL))["tracks"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def panel_tracks(panel=None):
|
|
20
|
+
return json.load(open(panel))["tracks"] if panel else _default_tracks()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def zero_tracks(tracks=None) -> np.ndarray:
|
|
24
|
+
"""Bool per track: unrecorded means 0 (RNA-seq bigWigs never store zeros)."""
|
|
25
|
+
tracks = _default_tracks() if tracks is None else tracks
|
|
26
|
+
return np.array([t.get("modality") in ZERO_MODALITIES for t in tracks], dtype=bool)
|
zipoop/shard.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Parse and validate one teacher shard (a ``pooper-dna-v3`` hybrid archive) into a typed record."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .masks import MASK_FORMAT, decode_mask
|
|
13
|
+
from .network import ModelConfig
|
|
14
|
+
|
|
15
|
+
SHARD_FORMAT = "pooper-dna-v3"
|
|
16
|
+
HYBRID_FORMAT = "fft-plus-dna-residual-v1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ShardView:
|
|
20
|
+
"""Read-only mapping over one window inside a merged per-chromosome npz (members "<window>/<key>")."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, npz, key: str, members):
|
|
23
|
+
self._npz, self.key, self._members = npz, key, frozenset(members)
|
|
24
|
+
|
|
25
|
+
def __contains__(self, name):
|
|
26
|
+
return name in self._members
|
|
27
|
+
|
|
28
|
+
def __getitem__(self, name):
|
|
29
|
+
if name not in self._members:
|
|
30
|
+
raise KeyError(name)
|
|
31
|
+
return self._npz[f"{self.key}/{name}"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Shard:
|
|
36
|
+
key: str
|
|
37
|
+
shape: tuple[int, int] # (length, tracks)
|
|
38
|
+
metadata: dict
|
|
39
|
+
config: ModelConfig
|
|
40
|
+
dna: np.ndarray # (L,) uint8
|
|
41
|
+
valid: np.ndarray # (L, T) bool
|
|
42
|
+
parameters: dict[str, np.ndarray]
|
|
43
|
+
mean: np.ndarray # per-track target scaling and calibration, (T,)
|
|
44
|
+
power: np.ndarray
|
|
45
|
+
gain: np.ndarray
|
|
46
|
+
offset: np.ndarray
|
|
47
|
+
fft_indices: np.ndarray # (T, K) retained rfft bins
|
|
48
|
+
fft_coefficients: np.ndarray # (T, K) complex64
|
|
49
|
+
final_gain: np.ndarray
|
|
50
|
+
final_offset: np.ndarray
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@contextmanager
|
|
54
|
+
def _opened(source):
|
|
55
|
+
if isinstance(source, (str, os.PathLike)):
|
|
56
|
+
with np.load(source, allow_pickle=False) as z:
|
|
57
|
+
yield z, Path(source).stem
|
|
58
|
+
else:
|
|
59
|
+
yield source, source.key
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_shard(source) -> Shard:
|
|
63
|
+
"""``source``: a shard path, or a ShardView from :class:`zipoop.Dataset`."""
|
|
64
|
+
with _opened(source) as (z, key):
|
|
65
|
+
if "format" not in z or str(z["format"]) != SHARD_FORMAT:
|
|
66
|
+
raise ValueError(f"Unsupported shard format: {z['format'] if 'format' in z else None}")
|
|
67
|
+
if "hybrid_format" not in z or str(z["hybrid_format"]) != HYBRID_FORMAT:
|
|
68
|
+
raise ValueError("Unsupported hybrid archive")
|
|
69
|
+
if "valid_encoding" not in z or str(z["valid_encoding"]) != MASK_FORMAT:
|
|
70
|
+
raise ValueError("Unsupported mask encoding")
|
|
71
|
+
shape = tuple(int(n) for n in z["shape"])
|
|
72
|
+
if len(shape) != 2:
|
|
73
|
+
raise ValueError("Invalid shape")
|
|
74
|
+
length, tracks = shape
|
|
75
|
+
dna = z["dna"].copy()
|
|
76
|
+
if dna.shape != (length,) or np.any(dna > 4):
|
|
77
|
+
raise ValueError("Invalid DNA data in archive")
|
|
78
|
+
names = json.loads(z["parameter_names"].tobytes())
|
|
79
|
+
if len(names) != len(set(names)):
|
|
80
|
+
raise ValueError("Duplicate parameter names")
|
|
81
|
+
shard = Shard(
|
|
82
|
+
key=key, shape=shape, metadata=json.loads(z["metadata"].tobytes()),
|
|
83
|
+
config=ModelConfig.from_json(json.loads(z["model_config"].tobytes())),
|
|
84
|
+
dna=dna, valid=decode_mask(z["valid_v2"].tobytes(), shape),
|
|
85
|
+
parameters={name: z[f"parameter_{i}"].copy() for i, name in enumerate(names)},
|
|
86
|
+
**{name: z[name].copy() for name in ("mean", "power", "gain", "offset")},
|
|
87
|
+
fft_indices=z["fft_indices"].copy(), fft_coefficients=z["fft_coefficients"].copy(),
|
|
88
|
+
final_gain=z["final_gain"].copy(), final_offset=z["final_offset"].copy())
|
|
89
|
+
_validate(shard)
|
|
90
|
+
return shard
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _validate(s: Shard) -> None:
|
|
94
|
+
length, tracks = s.shape
|
|
95
|
+
if any(a.shape != (tracks,) for a in (s.mean, s.power, s.gain, s.offset)):
|
|
96
|
+
raise ValueError("Invalid track scales")
|
|
97
|
+
i, c = s.fft_indices, s.fft_coefficients
|
|
98
|
+
if (i.ndim != 2 or i.shape[0] != tracks or c.shape != i.shape or not np.issubdtype(i.dtype, np.integer)
|
|
99
|
+
or (i < 0).any() or (i > length // 2).any() or not np.isfinite(c).all()):
|
|
100
|
+
raise ValueError("Invalid FFT payload")
|
|
101
|
+
for a in (s.final_gain, s.final_offset):
|
|
102
|
+
if a.shape != (tracks,) or not np.isfinite(a).all():
|
|
103
|
+
raise ValueError("Invalid final-signal calibration")
|
|
104
|
+
if (s.final_gain < 0).any():
|
|
105
|
+
raise ValueError("Invalid final-signal calibration")
|
zipoop/teacher.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Teacher signal restoration. Rules (shared by teacher and real data, see evaluation.restore_real):
|
|
2
|
+
|
|
3
|
+
1. Valid positions use the decoded value. Missing positions: RNA-seq tracks are 0 and count as valid
|
|
4
|
+
(unrecorded = zero expression, and the archive mask agrees with "unrecorded" position by position);
|
|
5
|
+
all other tracks stay missing (ChIP/DNase/ATAC bigWigs record zeros explicitly, so missing is real).
|
|
6
|
+
2. The raw network output at missing positions is never used as a target: training never constrained it.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import torch
|
|
14
|
+
|
|
15
|
+
from .decoder import Decoder
|
|
16
|
+
from .panel import zero_tracks
|
|
17
|
+
from .shard import Shard, read_shard
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class TeacherWindow:
|
|
22
|
+
key: str
|
|
23
|
+
dna: torch.Tensor # (L,) int64, 0-3 = ACGT, 4 = N
|
|
24
|
+
signal: torch.Tensor # (L, T) float32, 0 where missing
|
|
25
|
+
valid: torch.Tensor # (L, T) bool, all True for RNA tracks
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def restore(decoder: Decoder, zero) -> tuple[torch.Tensor, torch.Tensor]:
|
|
29
|
+
y = decoder.decode(missing_nan=False)
|
|
30
|
+
valid = torch.from_numpy(np.ascontiguousarray(decoder.valid)).to(y.device)
|
|
31
|
+
zero = torch.as_tensor(np.asarray(zero, dtype=bool), device=y.device)
|
|
32
|
+
return torch.where(valid, y, torch.zeros_like(y)), valid | zero[None, :]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_window(source, device="cpu", zero=None) -> TeacherWindow:
|
|
36
|
+
"""``source``: shard path, ShardView, or an already parsed Shard."""
|
|
37
|
+
shard = source if isinstance(source, Shard) else read_shard(source)
|
|
38
|
+
zero = zero_tracks() if zero is None else np.asarray(zero, dtype=bool)
|
|
39
|
+
if zero.shape != (shard.shape[1],):
|
|
40
|
+
raise ValueError(f"zero_tracks length {zero.shape} does not match {shard.shape[1]} tracks in {shard.key}")
|
|
41
|
+
signal, valid = restore(Decoder(shard, device), zero)
|
|
42
|
+
return TeacherWindow(shard.key, torch.from_numpy(shard.dna.astype(np.int64)).to(signal.device),
|
|
43
|
+
signal.float(), valid)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zipoop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Decoder and reader for Zipoop teacher shards (per-window neural compression of ENCODE signal tracks)
|
|
5
|
+
Author: zatumout
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Dataset, https://huggingface.co/datasets/zatumout/zipoop-teacher-shards
|
|
8
|
+
Keywords: genomics,ENCODE,neural compression,distillation
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: numpy>=1.26
|
|
15
|
+
Requires-Dist: torch>=2.2
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# zipoop
|
|
21
|
+
|
|
22
|
+
Decoder and reader for the Zipoop teacher shards (dataset: https://huggingface.co/datasets/zatumout/zipoop-teacher-shards): 89,438 windows of 32 kb, 1,885 ENCODE tracks, stored as one `<chrom>.npz` per chromosome (`weights_npz`, with `MANIFEST.tsv`).
|
|
23
|
+
|
|
24
|
+
## Data layout
|
|
25
|
+
|
|
26
|
+
`<root>/<chrom>.npz` (25 files) plus `MANIFEST.tsv` (chrom, windows, members, bytes, sha256). Each file is a deflate zip with members `<window>/<key>.npy` (161 arrays per window), windows in genome order. Window names are `<chrom>_<start>_<end>`.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from zipoop import Dataset
|
|
32
|
+
with Dataset("/path/to/weights_npz") as ds:
|
|
33
|
+
w = ds.load_window(ds.windows("chr21")[0]) # TeacherWindow: key, dna (L,), signal (L, T), valid (L, T)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
zipoop verify /path/to/weights_npz # size + sha256 against MANIFEST.tsv
|
|
38
|
+
zipoop show /path/to/weights_npz chr21_10027008_10059776
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Lower level: `Dataset.read(window)` gives the parsed `Shard`, `Decoder(shard).decode(start, end, tracks)` decodes any slice or track subset. Evaluation helpers (`window_pearson`, `summarize`, `calibrate`, `restore_real`) are in `zipoop.evaluation`.
|
|
42
|
+
|
|
43
|
+
Rules baked into `load_window` (see `zipoop/teacher.py`): RNA-seq tracks are 0 and valid where unrecorded; other tracks stay missing there. Opening a chromosome file parses its whole zip directory (chr1 about 3.6 s and 0.9 GB), so keep the `Dataset` open; forked DataLoader workers reopen their own handles.
|
|
44
|
+
|
|
45
|
+
Only the shard variant in this dataset is supported (hybrid `pooper-dna-v3`, max-pool, sinusoidal positions, mask `track-rle-lzma-v2`); other variants raise a clear error.
|
|
46
|
+
|
|
47
|
+
Tests: `ZIPOOP_DATA=<weights_npz> pytest tests` (the pooper-equivalence test also needs the original shards and the pooper package).
|
|
48
|
+
|
|
49
|
+
## License and data terms
|
|
50
|
+
|
|
51
|
+
The code is MIT-licensed (see `LICENSE`; keep the copyright notice "zatumout"). The shard dataset is released separately under CC BY 4.0 (credit: zatumout), commercial use and model distillation allowed. The shards and `zipoop/data/encode-panel_1885.json` are derived from public ENCODE tracks: when you use them, also cite and acknowledge the ENCODE Consortium as described at https://www.encodeproject.org/help/citing-encode.
|