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/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.