altar-conservation 0.3.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.
@@ -0,0 +1,15 @@
1
+ # Copyright 2026 Riya Sinha
2
+ """Base-wise conservation score tracks, such as UCSC phyloP 100-way, as Altar annotation sources."""
3
+
4
+ from altar_conservation.source import ConservationAnnotationSource, PhyloP100wayAnnotationSource
5
+ from altar_conservation.tracks import PHYLOP100WAY, PHYLOP100WAY_ANNOTATIONS, TRACKS, ConservationTrack
6
+
7
+
8
+ __all__ = [
9
+ "PHYLOP100WAY",
10
+ "PHYLOP100WAY_ANNOTATIONS",
11
+ "TRACKS",
12
+ "ConservationAnnotationSource",
13
+ "ConservationTrack",
14
+ "PhyloP100wayAnnotationSource",
15
+ ]
@@ -0,0 +1,319 @@
1
+ # Copyright 2026 Riya Sinha
2
+ """Build a track's position score dataset from its upstream bigWig file, such as UCSC's ``hg38.phyloP100way.bw``.
3
+
4
+ `build_dataset` checks the file's MD5 against the track's `upstream_md5`, then converts each primary contig
5
+ (chr1-22, X, Y and M) in its own spawned process: the bigWig's records are read with pybigtools, their 0-based,
6
+ half-open intervals become one row per base with a 1-based `pos` (`interval_rows`), and the rows are written with
7
+ `altar.sources.PartitionedWriter` in the layout `altar.sources.POSITION_SCORE_METADATA` describes: Hive
8
+ ``chr=<contig>/``, 128,000-row groups, `pos` delta-encoded, the track, release and genome build in every footer.
9
+ Records on other contigs (alts, unplaced scaffolds) are not kept. The output is a local directory; copy it to a
10
+ bucket yourself.
11
+ """
12
+
13
+ from __future__ import annotations
14
+ import argparse
15
+ import hashlib
16
+ import itertools
17
+ import multiprocessing
18
+ import os
19
+ import time
20
+ from concurrent.futures import ProcessPoolExecutor
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ import numpy as np
26
+ import pyarrow as pa
27
+ import pyarrow.parquet as pq
28
+ from altar.sources import (
29
+ POSITION_SCORE_METADATA,
30
+ PartitionedWriter,
31
+ build_output,
32
+ position_score_release_fields,
33
+ )
34
+
35
+ from altar_conservation.tracks import TRACKS, ConservationTrack
36
+
37
+
38
+ if TYPE_CHECKING:
39
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
40
+
41
+
42
+ PRIMARY_CONTIGS = (*(f"chr{number}" for number in range(1, 23)), "chrX", "chrY", "chrM")
43
+ """The contigs a position score dataset keeps, in UCSC's spelling, which is also Altar's."""
44
+ SCHEMA = pa.schema(
45
+ [
46
+ pa.field("chr", pa.string(), nullable=False),
47
+ pa.field("pos", pa.int32(), nullable=False),
48
+ pa.field("score", pa.float32(), nullable=False),
49
+ ]
50
+ )
51
+ """One row per scored base: the contig, the 1-based position and the score."""
52
+ ROW_GROUP_ROWS = 128_000
53
+ PARQUET_OPTIONS: dict[str, Any] = {
54
+ # `chr` repeats and scores take few distinct values; positions increase, so they are delta-encoded, which needs
55
+ # dictionary encoding off for `pos`.
56
+ "use_dictionary": ["chr", "score"],
57
+ "column_encoding": {"pos": "DELTA_BINARY_PACKED"},
58
+ }
59
+ _STEM = "scores"
60
+ _LABEL = "position score"
61
+ # How many bigWig records a worker converts at once.
62
+ _CHUNK_RECORDS = 4_000_000
63
+ _RECORD = np.dtype([("start", np.int64), ("end", np.int64), ("value", np.float64)])
64
+ _MD5_BLOCK_BYTES = 8 * 1024 * 1024
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ContigSummary:
69
+ """What one contig contributed: its rows, its file and the file's size and row groups."""
70
+
71
+ contig: str
72
+ rows: int
73
+ file: Path
74
+ bytes: int
75
+ row_groups: int
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class BuildSummary:
80
+ """What `build_dataset` wrote."""
81
+
82
+ track: str
83
+ contigs: tuple[ContigSummary, ...]
84
+ skipped_contigs: int
85
+ seconds: float
86
+
87
+ @property
88
+ def rows(self) -> int:
89
+ """Every row written."""
90
+ return sum(contig.rows for contig in self.contigs)
91
+
92
+ @property
93
+ def files(self) -> tuple[Path, ...]:
94
+ """The files written, one per contig."""
95
+ return tuple(contig.file for contig in self.contigs)
96
+
97
+ @property
98
+ def bytes(self) -> int:
99
+ """The bytes written."""
100
+ return sum(contig.bytes for contig in self.contigs)
101
+
102
+ @property
103
+ def row_groups(self) -> int:
104
+ """The row groups written."""
105
+ return sum(contig.row_groups for contig in self.contigs)
106
+
107
+
108
+ def interval_rows(
109
+ starts: np.ndarray, ends: np.ndarray, values: np.ndarray, *, after: int = 0, label: str = "the input"
110
+ ) -> tuple[np.ndarray, np.ndarray]:
111
+ """Return the rows of bigWig records: each base's 1-based position (int32) and its score (float32).
112
+
113
+ A record is a 0-based, half-open interval `[start, end)` with one value; a record of several bases becomes one
114
+ row per base, and unscored bases between records have no row. The records must be sorted and must not overlap,
115
+ so positions strictly increase, and must start after the 1-based position `after`, the last row of the previous
116
+ chunk of the same contig. Raises `ValueError` naming `label` otherwise, or for a value that is not finite.
117
+ """
118
+ starts = np.asarray(starts, dtype=np.int64)
119
+ ends = np.asarray(ends, dtype=np.int64)
120
+ lengths = ends - starts
121
+ if (lengths < 1).any() or (starts < 0).any():
122
+ index = int(np.flatnonzero((lengths < 1) | (starts < 0))[0])
123
+ msg = f"{label} has an empty or negative interval [{starts[index]}, {ends[index]})"
124
+ raise ValueError(msg)
125
+ scores = np.asarray(values, dtype=np.float32)
126
+ if not np.isfinite(scores).all():
127
+ index = int(np.flatnonzero(~np.isfinite(scores))[0])
128
+ msg = f"{label} has a value that is not finite ({values[index]}) at [{starts[index]}, {ends[index]})"
129
+ raise ValueError(msg)
130
+ if len(starts) and int(lengths.max()) == 1:
131
+ positions = starts + 1 # one base per record, as UCSC's phyloP files store
132
+ else:
133
+ # Each base's offset from its record's first base, added to that base's 1-based position.
134
+ firsts = np.repeat(starts + 1, lengths)
135
+ offsets = np.arange(len(firsts), dtype=np.int64) - np.repeat(np.cumsum(lengths) - lengths, lengths)
136
+ positions = firsts + offsets
137
+ scores = np.repeat(scores, lengths)
138
+ if len(positions):
139
+ steps = np.diff(positions, prepend=after)
140
+ if (steps < 1).any():
141
+ index = int(np.flatnonzero(steps < 1)[0])
142
+ previous = after if index == 0 else int(positions[index - 1])
143
+ msg = (
144
+ f"{label} is not sorted, or has overlapping records: position {int(positions[index])} follows "
145
+ f"{previous}"
146
+ )
147
+ raise ValueError(msg)
148
+ if int(positions[-1]) > np.iinfo(np.int32).max:
149
+ msg = f"{label} has a position beyond int32: {int(positions[-1])}"
150
+ raise ValueError(msg)
151
+ return positions.astype(np.int32), scores
152
+
153
+
154
+ def contig_batches(
155
+ contig: str, records: Iterable[tuple[int, int, float]], *, chunk_records: int = _CHUNK_RECORDS
156
+ ) -> Iterator[pa.Table]:
157
+ """Yield `contig`'s rows from its bigWig records, `(start, end, value)` in order, as tables of `SCHEMA`."""
158
+ iterator = iter(records)
159
+ after = 0
160
+ label = f"the bigWig's {contig}"
161
+ while True:
162
+ chunk = np.fromiter(itertools.islice(iterator, chunk_records), dtype=_RECORD)
163
+ if not len(chunk):
164
+ return
165
+ positions, scores = interval_rows(chunk["start"], chunk["end"], chunk["value"], after=after, label=label)
166
+ after = int(positions[-1])
167
+ yield pa.table(
168
+ [pa.repeat(pa.scalar(contig), len(positions)), pa.array(positions), pa.array(scores)], schema=SCHEMA
169
+ )
170
+
171
+
172
+ def check_md5(path: str | os.PathLike[str], expected: str) -> None:
173
+ """Raise `ValueError` unless the file's MD5 is `expected`, reading it in blocks."""
174
+ digest = hashlib.md5(usedforsecurity=False)
175
+ with Path(path).open("rb") as handle:
176
+ while block := handle.read(_MD5_BLOCK_BYTES):
177
+ digest.update(block)
178
+ if digest.hexdigest() != expected.lower():
179
+ msg = f"{path} has MD5 {digest.hexdigest()}, not {expected}, the checksum its host publishes; download it again"
180
+ raise ValueError(msg)
181
+
182
+
183
+ def build_dataset(
184
+ track: ConservationTrack,
185
+ bigwig: str | os.PathLike[str],
186
+ output: str | os.PathLike[str],
187
+ *,
188
+ overwrite: bool = False,
189
+ workers: int | None = None,
190
+ ) -> BuildSummary:
191
+ """Build `track`'s position score dataset from its upstream bigWig, a local file, in the local directory `output`.
192
+
193
+ The file's MD5 must be the track's `upstream_md5`. Each primary contig the file holds is converted in its own
194
+ spawned process, `workers` at a time (default: one per contig, up to the CPU count); other contigs are skipped.
195
+ `output` must be empty or absent; with `overwrite`, a previous build there is replaced. A failed build leaves
196
+ nothing behind.
197
+ """
198
+ started = time.monotonic()
199
+ source = Path(bigwig)
200
+ if not source.is_file():
201
+ msg = f"no bigWig file at {source}"
202
+ raise FileNotFoundError(msg)
203
+ check_md5(source, track.upstream_md5)
204
+ contigs = _contigs(source)
205
+ primary = [contig for contig in PRIMARY_CONTIGS if contig in contigs]
206
+ if not primary:
207
+ msg = f"{source} holds none of the primary contigs {', '.join(PRIMARY_CONTIGS)}"
208
+ raise ValueError(msg)
209
+ if workers is not None and workers < 1:
210
+ msg = f"workers must be positive, got {workers}"
211
+ raise ValueError(msg)
212
+ count = min(len(primary), workers or os.cpu_count() or 1)
213
+ metadata = track_metadata(track)
214
+ with build_output(Path(output), overwrite=overwrite, partition_column="chr", label=_LABEL) as directory:
215
+ tasks = [(source, directory, contig, metadata) for contig in primary]
216
+ summaries = _convert(tasks, count)
217
+ return BuildSummary(
218
+ track=track.name,
219
+ contigs=tuple(summary for summary in summaries if summary is not None),
220
+ skipped_contigs=len(contigs) - len(primary),
221
+ seconds=time.monotonic() - started,
222
+ )
223
+
224
+
225
+ def track_metadata(track: ConservationTrack) -> dict[str, str]:
226
+ """Return the footer metadata every file of `track`'s dataset records (`POSITION_SCORE_METADATA`)."""
227
+ return POSITION_SCORE_METADATA.write(
228
+ position_score_release_fields(contract=track.contract, release=track.release, genome_build=track.genome_build)
229
+ )
230
+
231
+
232
+ def _contigs(source: Path) -> dict[str, int]:
233
+ import pybigtools # noqa: PLC0415 - the `build` extra
234
+
235
+ with pybigtools.open(str(source)) as reader:
236
+ return dict(reader.chroms())
237
+
238
+
239
+ def _convert(tasks: Sequence[tuple[Path, Path, str, Mapping[str, str]]], count: int) -> list[ContigSummary | None]:
240
+ if count == 1:
241
+ return [_convert_contig(*task) for task in tasks]
242
+ # `spawn` starts each worker from a fresh interpreter: forking a process with threads running can deadlock it.
243
+ context = multiprocessing.get_context("spawn")
244
+ with ProcessPoolExecutor(max_workers=count, mp_context=context) as pool:
245
+ futures = [pool.submit(_convert_contig, *task) for task in tasks]
246
+ try:
247
+ return [future.result() for future in futures]
248
+ except BaseException:
249
+ for future in futures:
250
+ future.cancel()
251
+ raise
252
+
253
+
254
+ def _convert_contig(source: Path, output: Path, contig: str, metadata: Mapping[str, str]) -> ContigSummary | None:
255
+ import pybigtools # noqa: PLC0415 - the `build` extra
256
+
257
+ with pybigtools.open(str(source)) as reader:
258
+ return write_contig(output, contig, reader.records(contig), metadata)
259
+
260
+
261
+ def write_contig(
262
+ output: Path, contig: str, records: Iterable[tuple[int, int, float]], metadata: Mapping[str, str]
263
+ ) -> ContigSummary | None:
264
+ """Write `contig`'s rows from its bigWig records to `output/chr=<contig>/`; return `None` if it has none.
265
+
266
+ `records` are `(start, end, value)`, 0-based and half-open, in order, as pybigtools reads them; `metadata` is
267
+ `track_metadata`'s. The file is written under a ``.partial`` name and renamed when it is complete.
268
+ """
269
+ writer = PartitionedWriter(
270
+ output,
271
+ _STEM,
272
+ schema=SCHEMA,
273
+ partition_column="chr",
274
+ row_group_rows=ROW_GROUP_ROWS,
275
+ metadata=metadata,
276
+ parquet_options=PARQUET_OPTIONS,
277
+ )
278
+ rows = 0
279
+ try:
280
+ for batch in contig_batches(contig, records):
281
+ writer.append_batch(contig, batch)
282
+ rows += batch.num_rows
283
+ files = writer.commit()
284
+ except BaseException:
285
+ writer.abort()
286
+ raise
287
+ if not files:
288
+ return None
289
+ [file] = files
290
+ return ContigSummary(
291
+ contig=contig,
292
+ rows=rows,
293
+ file=file,
294
+ bytes=file.stat().st_size,
295
+ row_groups=pq.ParquetFile(file).metadata.num_row_groups,
296
+ )
297
+
298
+
299
+ def main() -> None:
300
+ """Run the conservation track builder command."""
301
+ parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
302
+ parser.add_argument("--track", required=True, choices=sorted(TRACKS), help="The track to build")
303
+ parser.add_argument("--input", required=True, help="The track's upstream bigWig file, downloaded locally")
304
+ parser.add_argument("--output", required=True, help="The local directory to write the dataset to")
305
+ parser.add_argument("--overwrite", action="store_true", help="Replace a previous build in --output")
306
+ parser.add_argument("--workers", type=int, help="Contigs to convert at once, in processes (default: CPU count)")
307
+ args = parser.parse_args()
308
+ summary = build_dataset(TRACKS[args.track], args.input, args.output, overwrite=args.overwrite, workers=args.workers)
309
+ for contig in summary.contigs:
310
+ print(f"{contig.contig}\t{contig.rows}") # noqa: T201 - command-line report
311
+ print( # noqa: T201
312
+ f"wrote {summary.rows} rows of {summary.track} in {len(summary.files)} files and {summary.row_groups} row "
313
+ f"groups ({summary.bytes / 1e9:.2f} GB) to {args.output}; skipped {summary.skipped_contigs} non-primary "
314
+ f"contigs; {summary.seconds:.0f} s"
315
+ )
316
+
317
+
318
+ if __name__ == "__main__":
319
+ main()
File without changes
@@ -0,0 +1,164 @@
1
+ # Copyright 2026 Riya Sinha
2
+ """Conservation tracks as annotation sources, read from the published copy or an owned one.
3
+
4
+ `ConservationAnnotationSource` is `altar.sources.PositionScoreAnnotationSource` bound to one `ConservationTrack`:
5
+ it reads the track's position score dataset (the copy the Kundaje lab publishes by default, or an owned copy) and
6
+ resolves run spec options with `PublishedDataset.select_copy`. Each published track is a one-line subclass that names
7
+ its track, registered under the track's name: `PhyloP100wayAnnotationSource` is ``phylop100way``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self
12
+
13
+ from altar.sources import (
14
+ AnnotationContract,
15
+ OwnedDatasetCopy,
16
+ OwnedTableCopy,
17
+ PositionScoreAnnotationSource,
18
+ PublishedDataset,
19
+ unknown_options_message,
20
+ )
21
+
22
+ from altar_conservation.tracks import PHYLOP100WAY, ConservationTrack
23
+
24
+
25
+ if TYPE_CHECKING:
26
+ import os
27
+ from collections.abc import Mapping
28
+
29
+ from altar.connections import Connection
30
+ from altar.predicates import Predicate
31
+
32
+
33
+ class ConservationAnnotationSource(PositionScoreAnnotationSource):
34
+ """Annotate variants with one conservation track's score, from its published copy or an owned copy.
35
+
36
+ A subclass names its `track` and registers under `track.name`. Build it with `from_options`. The identity is the
37
+ track's contract with the dataset's release and genome build, so the published copy, a pulled copy and a copy
38
+ built with ``altar-conservation-build`` from the same upstream file declare one identity; ``indels="max"`` adds
39
+ ``+indels-max`` to the release. A track exists on one genome build, which a subclass declares as its
40
+ `supported_genome_builds`: phyloP 100-way is hg38, so materialization joins it only to hg38 variants, and on
41
+ another assembly a model's dependency on it reads as missing.
42
+ """
43
+
44
+ track: ClassVar[ConservationTrack]
45
+
46
+ def __init_subclass__(cls, **kwargs: Any) -> None:
47
+ """Declare the subclass's track's genome build as the one assembly it serves."""
48
+ super().__init_subclass__(**kwargs)
49
+ track = cls.__dict__.get("track")
50
+ if track is not None:
51
+ cls.supported_genome_builds = frozenset({track.genome_build})
52
+
53
+ # The run driver passes `connection` only to a `from_options` that names it, so the options are explicit.
54
+ @classmethod
55
+ def from_options( # noqa: PLR0913 - each run spec option is a keyword
56
+ cls,
57
+ *,
58
+ dataset: str | os.PathLike[str] | None = None,
59
+ location: str | None = None,
60
+ storage_options: Mapping[str, Any] | None = None,
61
+ connection: Connection | None = None,
62
+ indels: Literal["skip", "max"] = "skip",
63
+ genome_build: str | None = None,
64
+ name: str | None = None,
65
+ prioritize_predicate: Predicate | None = None,
66
+ **unknown: object,
67
+ ) -> Self:
68
+ """Build the source from run spec options, over the published copy or an owned Parquet copy.
69
+
70
+ - With no `dataset`, the source reads the published copy (`published_dataset()`) where it is hosted:
71
+ anonymously, or with `connection`'s or `storage_options`' options, from the primary ``gcs`` location
72
+ unless `location` names another, such as ``"hf"`` (which needs `altar[hf]`).
73
+ - `dataset` is an owned copy, as a path or a URI: one ``altar pull <track> --to <dir>`` made, or one
74
+ ``altar-conservation-build`` wrote. It is read with `connection`'s storage options for it, or with
75
+ `storage_options`.
76
+
77
+ `indels` is ``"skip"`` (SNVs only, the default) or ``"max"`` (every variant, by the greatest score over the
78
+ bases it touches; see `PositionScoreAnnotationSource`). `genome_build`, when given, must be the dataset's.
79
+ `name` renames the source, and `prioritize_predicate`, which a spec cannot spell, makes it gate
80
+ `prioritized` when built in code. The source reads no BigQuery table, so it takes neither `table` nor
81
+ `release`, which names a table's release. A `table` raises `ValueError` that says to pull the dataset
82
+ instead. An unknown `location` raises `ValueError` naming the published locations, and an option not named
83
+ here raises `ValueError` listing the valid ones.
84
+ """
85
+ if "table" in unknown:
86
+ msg = (
87
+ f"{cls.track.name} source option table={unknown['table']!r} names a BigQuery table, but BigQuery "
88
+ f"tables aren't supported yet; pull the dataset with `altar pull {cls.name} --to <dir>` and use "
89
+ "`dataset=`"
90
+ )
91
+ raise ValueError(msg)
92
+ if unknown:
93
+ raise ValueError(
94
+ unknown_options_message(cls.track.name, cls.from_options, unknown, code_only=_CODE_ONLY_OPTIONS)
95
+ )
96
+ published = cls.published_dataset()
97
+ if location is not None and dataset is None:
98
+ _check_location(cls, published, location)
99
+ copy = published.select_copy(
100
+ cls.track.name,
101
+ dataset=dataset,
102
+ storage_options=storage_options,
103
+ connection=connection,
104
+ location=location,
105
+ )
106
+ assert not isinstance(copy, OwnedTableCopy) # noqa: S101 - no table was passed, so it is a Parquet copy
107
+ uri = copy.dataset if isinstance(copy, OwnedDatasetCopy) else copy.uri
108
+ source = cls.from_dataset(
109
+ uri,
110
+ contract=cls.track.contract,
111
+ indels=indels,
112
+ genome_build=genome_build,
113
+ storage_options=copy.storage_options,
114
+ prioritize_predicate=prioritize_predicate,
115
+ name=name,
116
+ )
117
+ if dataset is None:
118
+ try:
119
+ # Reuses the footer `from_dataset` just read: nothing more is fetched.
120
+ published.check_release(uri, storage_options=copy.storage_options, all_files=False)
121
+ except ValueError as error:
122
+ msg = (
123
+ f"the published {cls.track.name} dataset at {uri} is not release {published.release!r} on "
124
+ f"{published.genome_build!r}, which this altar-conservation version reads ({error}); upgrade "
125
+ "altar-conservation, or name an owned dataset"
126
+ )
127
+ raise ValueError(msg) from error
128
+ return source
129
+
130
+ @classmethod
131
+ def published_dataset(cls) -> PublishedDataset:
132
+ """Return the track's published copy, which `from_options` reads by default."""
133
+ return cls.track.published
134
+
135
+ @classmethod
136
+ def annotation_contract(cls) -> AnnotationContract:
137
+ """Return the track's contract, which every instance implements, for ``altar sources``."""
138
+ return cls.track.contract
139
+
140
+
141
+ class PhyloP100wayAnnotationSource(ConservationAnnotationSource):
142
+ """Evolutionary conservation at each variant's bases, from UCSC's hg38 phyloP 100-way scores.
143
+
144
+ Positive scores mark bases that evolved more slowly than neutral across 100 vertebrates, negative faster. The
145
+ track is `PHYLOP100WAY`, registered as ``phylop100way``.
146
+ """
147
+
148
+ name = "phylop100way"
149
+ track = PHYLOP100WAY
150
+
151
+
152
+ _CODE_ONLY_OPTIONS = ("prioritize_predicate",)
153
+ """`from_options` keywords a run spec cannot spell, because their values are Python objects."""
154
+
155
+
156
+ def _check_location(source: type[ConservationAnnotationSource], published: PublishedDataset, location: str) -> None:
157
+ """Raise `ValueError` naming the published locations when `location` is not one of them."""
158
+ names = [published_location.name for published_location in published.locations]
159
+ if location not in names:
160
+ msg = f"the published {source.track.name} dataset has no location {location!r}; name one of: {', '.join(names)}"
161
+ raise ValueError(msg)
162
+
163
+
164
+ __all__ = ["ConservationAnnotationSource", "PhyloP100wayAnnotationSource"]
@@ -0,0 +1,87 @@
1
+ # Copyright 2026 Riya Sinha
2
+ """The conservation tracks the binding builds and the Kundaje lab publishes: one entry per published track.
3
+
4
+ A track is one upstream bigWig on one genome build, the `AnnotationContract` its scores implement (one float
5
+ column), and where its position score dataset (`altar.sources.POSITION_SCORE_METADATA`) is published. Adding a track
6
+ is an entry here, a source subclass, an entry point, a build and a publish.
7
+ """
8
+
9
+ from __future__ import annotations
10
+ from dataclasses import dataclass
11
+
12
+ from altar.sources import (
13
+ POSITION_SCORE_METADATA,
14
+ AnnotationColumn,
15
+ AnnotationContract,
16
+ PublishedDataset,
17
+ PublishedLocation,
18
+ position_score_release_fields,
19
+ )
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ConservationTrack:
24
+ """One published base-wise conservation track.
25
+
26
+ `contract` declares the track's one float column, whose name is also the track's name, its source's registry
27
+ name and its builder's ``--track``. `release` labels the upstream file (UCSC's file date, ``YYYY-MM``),
28
+ `upstream_url` is where the bigWig is downloaded from and `upstream_md5` the checksum its host publishes, which
29
+ the builder checks. `published` is where the Kundaje lab hosts the built dataset.
30
+ """
31
+
32
+ contract: AnnotationContract
33
+ genome_build: str
34
+ release: str
35
+ upstream_url: str
36
+ upstream_md5: str
37
+ published: PublishedDataset
38
+
39
+ @property
40
+ def name(self) -> str:
41
+ """The track's name: its contract's one column, such as ``phylop100way``."""
42
+ return self.contract.columns[0].name
43
+
44
+
45
+ def _published(contract: AnnotationContract, *, release: str, genome_build: str, path: str) -> PublishedDataset:
46
+ """Return the published copy of a track: the lab's bucket first, mirrored on Hugging Face."""
47
+ return PublishedDataset(
48
+ locations=(
49
+ PublishedLocation("gcs", f"gs://kundajelab-altar/conservation/{path}"),
50
+ PublishedLocation("hf", f"hf://datasets/altar-genomics/{path}"),
51
+ ),
52
+ identity=contract.identity(release=release, genome_build=genome_build),
53
+ release_metadata=POSITION_SCORE_METADATA,
54
+ release_fields=position_score_release_fields(contract=contract, release=release, genome_build=genome_build),
55
+ # A score is read by position, not by variant ID.
56
+ cluster_by=("chr", "pos"),
57
+ )
58
+
59
+
60
+ PHYLOP100WAY_ANNOTATIONS = AnnotationContract(
61
+ source_id="org.kundajelab.altar.annotation.phylop100way",
62
+ schema_version="1.0.0",
63
+ columns=(AnnotationColumn(name="phylop100way", dtype="float", label="phyloP 100-way"),),
64
+ )
65
+ """The phyloP 100-way annotation contract: one float column, ``phylop100way``.
66
+
67
+ The value is UCSC's phyloP score over the 100-vertebrate alignment at the variant's base: a base-wise
68
+ -log10 p-value of conservation (positive, slower evolution than neutral) or acceleration (negative, faster), from
69
+ about -20 to 10. A source scoring indels takes the greatest score over the bases the variant touches. A contract
70
+ names no genome build, so an hg19 copy would implement it too, under another identity.
71
+ """
72
+
73
+ PHYLOP100WAY = ConservationTrack(
74
+ contract=PHYLOP100WAY_ANNOTATIONS,
75
+ genome_build="hg38",
76
+ release="2015-05",
77
+ upstream_url="https://hgdownload.soe.ucsc.edu/goldenPath/hg38/phyloP100way/hg38.phyloP100way.bw",
78
+ upstream_md5="43858006bdf98145b6fd239490bd0478",
79
+ published=_published(PHYLOP100WAY_ANNOTATIONS, release="2015-05", genome_build="hg38", path="phylop100way-hg38"),
80
+ )
81
+ """UCSC hg38 phyloP 100-way (``hg38.phyloP100way.bw``, May 2015), published as ``phylop100way-hg38``."""
82
+
83
+ TRACKS: dict[str, ConservationTrack] = {track.name: track for track in (PHYLOP100WAY,)}
84
+ """Every published track by name, as the builder's ``--track`` names it."""
85
+
86
+
87
+ __all__ = ["PHYLOP100WAY", "PHYLOP100WAY_ANNOTATIONS", "TRACKS", "ConservationTrack"]
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.5
2
+ Name: altar-conservation
3
+ Version: 0.3.0
4
+ Summary: Base-wise conservation score tracks (UCSC phyloP) for Altar
5
+ Project-URL: Documentation, https://kundajelab.github.io/altar/
6
+ Author: Riya Sinha
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: altar[gcs]<0.4,>=0.3.0
19
+ Provides-Extra: build
20
+ Requires-Dist: pybigtools>=0.3.0; extra == 'build'
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
23
+ Requires-Dist: pytest>=8; extra == 'test'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # altar-conservation
27
+
28
+ This binding adds base-wise conservation scores to variants. Its one track today is UCSC's hg38 phyloP 100-way
29
+ (`hg38.phyloP100way.bw`, May 2015): `phylop100way`, an annotation source (`PhyloP100wayAnnotationSource`), adds a
30
+ `phylop100way` column, the phyloP score at the variant's base, from a Parquet copy of the track: the one the
31
+ Kundaje lab publishes, or one you own. phyloP is a signed -log10 p-value: positive where a base evolves more slowly
32
+ than neutral across 100 vertebrates (conserved), negative where it evolves faster.
33
+
34
+ ## Annotate variants from the published copy
35
+
36
+ The Kundaje lab publishes the track in Altar's position score layout (`PHYLOP100WAY.published`):
37
+
38
+ | Location | URI |
39
+ | --- | --- |
40
+ | `gcs` (default) | `gs://kundajelab-altar/conservation/phylop100way-hg38` |
41
+ | `hf` | `hf://datasets/altar-genomics/phylop100way-hg38` ([dataset card](https://huggingface.co/datasets/altar-genomics/phylop100way-hg38)) |
42
+
43
+ A run spec reads it with no options; the package depends on `altar[gcs]`, and the `hf` mirror needs `altar[hf]`:
44
+
45
+ ```toml
46
+ [sources.phylop]
47
+ type = "phylop100way"
48
+ ```
49
+
50
+ Hugging Face limits anonymous requests, so a large cohort read with `location = "hf"` can stall; read the bucket, or
51
+ pull a copy and name it with `dataset` ([published locations](https://kundajelab.github.io/altar/guides/published-datasets/#published-locations)).
52
+
53
+ In Python:
54
+
55
+ ```python
56
+ import asyncio
57
+
58
+ from altar_conservation import PhyloP100wayAnnotationSource
59
+
60
+ source = PhyloP100wayAnnotationSource.from_options() # the published copy
61
+ rows = asyncio.run(source.annotate(["chr7:140753336:A:T"]))
62
+ rows["chr7:140753336:A:T"]["phylop100way"] # 9.236
63
+ ```
64
+
65
+ The contract is `org.kundajelab.altar.annotation.phylop100way` (version 1.0.0, `PHYLOP100WAY_ANNOTATIONS`), one
66
+ float column `phylop100way`. The copy holds 2,837,915,604 scored bases of chr1–22, X, Y and M; a variant at an
67
+ unscored base, or on any other contig (alternate, unlocalized or unplaced), has no row.
68
+
69
+ `altar pull phylop100way --to <dir>` copies the published dataset (5.0 GB), checking each file's release; name the
70
+ copy with `dataset`. The options:
71
+
72
+ | Option | Meaning |
73
+ | --- | --- |
74
+ | `dataset` | An owned copy, as a path or a URI: a pulled copy, or one built with the builder below. |
75
+ | `location` | `gcs` (default) or `hf`, for the published copy. |
76
+ | `connection`, `storage_options` | How to reach a bucket. |
77
+ | `indels` | `skip` (default) scores SNVs only; `max` scores every variant (below). |
78
+ | `name` | Rename the source. |
79
+ | `genome_build` | Optional; must be `hg38`. |
80
+ | `prioritize_predicate` | In code only: a predicate over `phylop100way` that gates `prioritized`. |
81
+
82
+ `table` is not read yet: BigQuery would scan the 2.8-billion-row table for every chunk of variants, so the source
83
+ refuses it, before any other option, and asks for a pulled copy. An unknown option or `location` raises a
84
+ `ValueError` that lists the valid ones.
85
+
86
+ The track is on hg38 only. `altar run` stops before ingesting anything when the spec's `[run] genome_build` is
87
+ another assembly, naming the source.
88
+
89
+ With `indels = "max"`, a variant that is not an SNV is scored by the greatest score over the bases it touches, as
90
+ VEP takes them: a deletion's deleted bases (after the padding base), the two bases an insertion falls between, or
91
+ any other change's REF span. Unscored bases are ignored; a variant with none scored has no row. SNVs score the same
92
+ either way. The identity is the contract with release `2015-05` and `hg38`; with `indels = "max"` the release is
93
+ `2015-05+indels-max`, since it scores variants the default leaves out.
94
+
95
+ ## Build the dataset
96
+
97
+ ```console
98
+ pip install "altar-conservation[build]"
99
+ curl -O https://hgdownload.soe.ucsc.edu/goldenPath/hg38/phyloP100way/hg38.phyloP100way.bw
100
+ altar-conservation-build --track phylop100way --input hg38.phyloP100way.bw --output /scratch/phylop100way-hg38
101
+ ```
102
+
103
+ The `build` extra installs [pybigtools](https://github.com/jackh726/bigtools), which has wheels for Linux and
104
+ macOS. The builder checks the file's MD5 against UCSC's `md5sum.txt`, then converts each primary contig in its own
105
+ process (`--workers`, default one per contig up to the CPU count): it reads the contig's records, turns UCSC's 0-based
106
+ intervals into one row per base with a 1-based `pos`, checks that positions strictly increase, and writes
107
+ `chr=<contig>/scores.parquet` with `PartitionedWriter` in 128,000-row groups, `pos` delta-encoded, with the track,
108
+ release and genome build in every footer. Other contigs are skipped. On a 64-core server the build took 72 s
109
+ (15 s of it the MD5) and about 14 GB of memory across 25 processes, about 0.7 GB each. The output is a local
110
+ directory; copy it to a bucket yourself.
111
+
112
+ ## Data terms and citation
113
+
114
+ UCSC's phyloP files are "freely available for public use"; UCSC asks you to credit the data and cite the Genome
115
+ Browser. Please cite:
116
+
117
+ - Pollard KS, Hubisz MJ, Rosenbloom KR, Siepel A. Detection of nonneutral substitution rates on mammalian phylogenies. *Genome
118
+ Research* 2010;20(1):110–121. doi:10.1101/gr.097857.109.
119
+ - Siepel A, et al. Evolutionarily conserved elements in vertebrate, insect, worm, and yeast genomes. *Genome
120
+ Research* 2005;15(8):1034–1050. doi:10.1101/gr.3715005.
121
+ - The UCSC Genome Browser's latest database paper (<https://genome.ucsc.edu/cite.html>): Casper J, et al.
122
+ *Nucleic Acids Research* 2026. PMID 41251146.
123
+
124
+ [Cite these data](https://kundajelab.github.io/altar/integrations/evidence/#cite-these-data) lists these with the
125
+ other datasets Altar publishes.
126
+
127
+ The binding ships no data. Its contract is its own; the source and layout come from `altar`, and the binding is
128
+ tested against Altar's conformance suites.
@@ -0,0 +1,10 @@
1
+ altar_conservation/__init__.py,sha256=mZFg8fFPXfd985kJrv-_0Kntxf9Cuzn82Bf4K1IeiXk,510
2
+ altar_conservation/build_dataset.py,sha256=ioXyf8VaKJzohOZc_MsdM_NKTT62MG51rTsFXQs7uE8,13217
3
+ altar_conservation/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ altar_conservation/source.py,sha256=p6KT7ajBG0bDnwNGCvjVT27-BlHl7XoEE5AjcRaQUUQ,7885
5
+ altar_conservation/tracks.py,sha256=jce7ElYRWq_L89PgB21-1p-EveWZAeBWyGpsBRP4sDw,3823
6
+ altar_conservation-0.3.0.dist-info/METADATA,sha256=c-Ef6Z-fAlDYypFvWjVUN2C8O7Z2KPvtRkiFGjfkUGs,6630
7
+ altar_conservation-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ altar_conservation-0.3.0.dist-info/entry_points.txt,sha256=19Rg6OFs2NBf-EvtAAFV1CYDnAE7jbxAySUvlNvLvMc,174
9
+ altar_conservation-0.3.0.dist-info/licenses/LICENSE,sha256=WXdDrQ5KT8NmZrPwFTmbpTbvtOUy9dCL9er44qckaj0,1067
10
+ altar_conservation-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ altar-conservation-build = altar_conservation.build_dataset:main
3
+
4
+ [altar.annotation_sources]
5
+ phylop100way = altar_conservation:PhyloP100wayAnnotationSource
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Riya Sinha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.