aadr-subset
Declarative AADR panel subsetting from YAML selectors. Replaces ad-hoc
awk pipelines and one-off scripts with version-stable,
PR-reviewable cohort definitions. Built on top of
aadr-resolve for
cross-AADR-version sample-ID mapping.
# britain_iron_age.yaml
populations: ["England_IA*"] # matches England_IA + v62 .AG/.SG/.DG variants
date: {min_calbp: 1900, max_calbp: 2400}
min_coverage: 0.3 # 1240k-target coverage (unitless ratio)
exclude:
individual_ids: [I12345] # known contaminated sample
$ aadr-subset select britain_iron_age.yaml v66.HO.aadr.PUB.anno -o cohort.ids
Selector: britain_iron_age.yaml (sha256:1a2b3c4...d5e6f7g)
.anno: v66.HO.aadr.PUB.anno (v66.0, class E)
Matched 45 samples across 1 population.
Per-population: England_IA=45
Wrote cohort.ids (45 lines)
Done in 0.18s (parse 0.16s, eval 0.02s, write 0.00s).
$ plink2 --pfile aadr_v66 --keep cohort.ids --make-pgen --out britain_iron_age
Why it exists
Ancient-DNA workflows live and die on cohort definitions — which samples
go into this analysis. Today that's typically a hand-curated set of
Group_ID literals in someone's shell script, prone to: silent breakage
when AADR releases a new version with renamed labels; no version pinning
in commit history; no way to share the exact cohort between collaborators
short of swapping .ind files.
aadr-subset makes the cohort itself a first-class artifact:
- Selector YAMLs are version-stable. They cite AADR releases via
tested_against:metadata; theselector_signature(RFC 8785 JCS SHA-256 over the canonical form) gives you a hash that survives YAML formatting churn. - Reviewable in PRs. The grammar is flat (top-level AND with
one-level
any:OR and one-levelexclude:NOT). What you see is what runs. - Cross-version via
aadr-resolve.resolve_to_version:lifts Individual_IDs from an older release to the newer one through the GID-stable bridge + MID-rename map. - Six subcommands cover the full lifecycle:
validate,select,inspect,report,diff,template.
Install
pip install aadr-subset
Python 3.11+. The only external dependency is aadr-resolve, pulled in automatically.
For development setup see CONTRIBUTING.md.
The six subcommands
validate SELECTOR.yaml
JSON-schema + semantic-constraint check on a selector. No .anno
required. Useful as a CI gate.
$ aadr-subset validate britain_iron_age.yaml
# exit 0 on valid; exit 4 on schema or semantic violation
# Errors carry precise file:line:col + JSON pointer:
$ aadr-subset validate broken.yaml
broken.yaml:7:5: at /populations/2: 42 is not of type 'string'
broken.yaml:12:3: at /any/0/min_coverage: -0.5 is less than the minimum of 0
select SELECTOR.yaml ANNO.anno [ANNO2.anno …] [-o PATH] [--format ids|tsv|json]
The main case: materialize a selector against a target .anno and
write matched sample IDs / TSV / JSON.
aadr-subset select britain_iron_age.yaml v66.HO.aadr.PUB.anno -o cohort.ids
aadr-subset select britain_iron_age.yaml v66.HO.aadr.PUB.anno --format tsv -o cohort.tsv
aadr-subset select britain_iron_age.yaml v66.HO.aadr.PUB.anno --format json -o cohort.json
Stratified sampling caps can be specified inline on the CLI (or in the selector YAML — selector wins per-field):
# cap to 1 library per individual (dedup .AG/.SG/.DG triplicates),
# then at most 50 samples per population
aadr-subset select europe_neolithic.yaml v66.HO.aadr.PUB.anno \
--max-per-individual 1 --max-per-population 50 -o cohort.ids
Multi-anno select (v0.4+): pass more than one .anno to merge
cohorts across releases in a single command. The selector is evaluated
against each .anno independently; results are union-deduplicated on
genetic_id (newer-version rows win on collision):
aadr-subset select britain_iron_age.yaml v44.3_HO.anno v66.HO.aadr.PUB.anno \
-o cohort.ids
TSV output gains a source_version column identifying which .anno
each row came from. JSON output gains anno_versions, anno_files, and
per_anno_n_matched keys alongside the single-anno fields for
backwards compatibility. resolve_to_version: selectors are
incompatible with multi-anno mode (hard error — use single-anno +
--source-anno for cross-version IID lifting instead).
Cross-version flow (selector defined against an older release than the materialized one):
# britain_v62_lift.yaml
individual_ids: [I12345, I12346]
source_version: v62.0
resolve_to_version: v66.0
aadr-subset select britain_v62_lift.yaml v66.HO.aadr.PUB.anno \
--source-anno v62.0_HO_public.anno \
-o lifted.ids
The positional .anno is the target (where the lifted cohort
materializes); --source-anno is the source (where the selector's
Individual_IDs are originally defined). aadr-resolve bridges the two.
v62.0 inputs (class D — no native coverage column) need a derived proxy
for min_coverage: filters:
aadr-subset select britain_iron_age.yaml v62.0_HO_public.anno \
--coverage-derive snps_hit_1240k -o cohort.ids
inspect SELECTOR.yaml ANNO.anno
Dry-run: shows what a selector matches without writing any file. Always exits 0 — meant for debugging selector logic.
$ aadr-subset inspect britain_iron_age.yaml v66.HO.aadr.PUB.anno
Selector: britain_iron_age.yaml
.anno: v66.HO.aadr.PUB.anno (v66.0, class E, 27,755 samples)
Matched: 45 samples across 1 population
Per-population breakdown:
England_IA 45
Branch contributions:
top_level 45
Date range of matched: 1934 - 2398 calBP (median 2103)
Coverage range: 0.34 - 4.81x (median 1.28)
Selector signature: sha256:1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7a8b9c0d1e
When sampling caps are active a "Downsampled" section appears (v0.3+). Per-population entries are listed individually; per-individual drops are aggregated (they can run into the thousands; the JSON output preserves per-IID detail for callers that need it):
$ aadr-subset inspect europe_neolithic.yaml v66.HO.aadr.PUB.anno \
--max-per-individual 1 --max-per-population 50
...
Matched: 312 samples across 8 populations
Downsampled:
Anatolia_N 12 samples dropped
Iran_N 8 samples dropped
per-individual aggregate: 177 samples dropped across 177 individual(s)
report SELECTOR.yaml ANNO.anno [-o PATH] [--format tsv|json]
Per-population aggregates: how many samples each Group_ID contributed, with date range and coverage stats.
$ aadr-subset report britain_iron_age.yaml v66.HO.aadr.PUB.anno
group_id n_matched n_in_anno pct_matched date_min_calbp date_max_calbp coverage_median
England_IA 45 51 88.2 1934 2398 1.28
--include-empty-groups adds rows for .anno groups that matched
zero samples (useful for population-survey workflows).
diff SELECTOR_A.yaml SELECTOR_B.yaml ANNO.anno [-o PATH] [--format human|json]
Set-difference of two selectors against the same .anno: which samples
does A match that B doesn't, and vice versa, plus a per-population
delta. Always exits 0 — diagnostic, not a gate. Useful for PR review
of selector changes.
$ aadr-subset diff old.yaml new.yaml v66.HO.aadr.PUB.anno
Selector A: old.yaml (sha256:1a2b3c4...d5e6f7g)
Selector B: new.yaml (sha256:9z8y7x6...w5v4u3t)
.anno: v66.HO.aadr.PUB.anno (v66.0, class E)
A only: 5 samples
B only: 12 samples
Both: 38 samples
Per-population delta:
group_id A B delta
England_IA 43 40 -3
England_IA-o 0 10 +10
England_BellBeaker 0 2 +2
A only sample preview: ['I12345', 'I12346', 'I12347', 'I12348', 'I12349']
B only sample preview: ['I20001', 'I20002', ...] (+2 more)
--format json -o diff.json writes a structured object with
a_only[], b_only[], both[], per_population_delta[] arrays plus
both signatures — suitable for pipeline integration / dashboards.
template [NAME] [-o PATH]
Ships starter selectors for common cohorts. No-arg form lists
shipped templates; arg form emits the verbatim YAML (comments + metadata
block preserved) to stdout or --out PATH.
$ aadr-subset template
bronze_age_europe
iron_age_britain
modern_european
neolithic_anatolia
viking_period_scandinavian
wsh_steppe_pool
$ aadr-subset template iron_age_britain -o britain.yaml
# britain.yaml now contains a working starting point — edit + extend.
All shipped templates are verified against AADR v62.0 and v66.0 —
each template's tested_against: metadata reflects the releases it
resolves to non-zero matches against.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Soft validation failure (e.g. zero-match without --allow-empty, --strict-resolve missing IIDs) |
| 2 | I/O failure (file not found, .anno schema unrecognized, etc.) |
| 3 | Invariant violation (internal consistency check failed — please file an issue) |
| 4 | Usage error (schema violation, flag misuse, unknown template) |
| 70 | Internal error (uncaught exception escape hatch — please file an issue) |
Selector grammar (overview)
Flat — one level of nesting maximum. Top-level keys AND-combine.
# Top-level AND
populations: [Western_HG, "England_*"] # group_id literals + fnmatch globs (v0.2+)
individual_ids: [Loschbour, KO1] # match against individual_id
individual_ids_source: ids.txt # newline-delimited file
modern_only: true # shorthand: date_calbp <= 70
min_coverage: 0.3
coverage_column: snps_hit_1240k # override; selector-side wins over --coverage-derive
date:
min_calbp: 1900
max_calbp: 2400
source_version: v62.0 # cross-version lift
resolve_to_version: v66.0
# One-level OR (matches any branch)
any:
- populations: [Western_HG]
min_coverage: 1.0
- populations: [Eastern_HG]
min_coverage: 0.5
# One-level NOT-of-OR (drops matches)
exclude:
group_ids: [English.SG, "*_o.SG"] # literals + globs
individual_ids: [I12345]
# Stratified sampling caps (v0.3+; applied after exclude, before dedup)
sampling:
max_per_population: 50 # cap per group_id (integer ≥ 1)
max_per_individual: 1 # cap per individual_id (1 = pick best library)
policy: top_coverage # default; v0.3 ships only this
Group_ID globs (v0.2+): any string containing *, ?, or [abc]
is treated as an fnmatch pattern against the target .anno's
Group_IDs. Patterns work in populations:, exclude.group_ids:, and
any-branch populations:. The selector signature hashes the pattern,
not the resolved set — so the same selector against v62 vs v66 produces
the same signature even when the pattern resolves to different concrete
labels. A pattern that matches zero Group_IDs surfaces as a warning
(CLI: stderr; library API: logging.getLogger("aadr_subset")) — likely
a typo.
Stratified sampling (v0.3+): sampling.max_per_population /
max_per_individual cap the cohort within each Group_ID / Individual_ID.
Per-individual fires first, then per-population — max_per_individual: 1
is the canonical "one library per individual" dedup; combined with a
per-population cap it picks the cap-many distinct individuals with the
highest coverage. --max-per-population N / --max-per-individual N
CLI flags also work; selector wins per-field. Both feed the signature
(intent-not-expansion — same caps against v62 vs v66 = same hash). On
class-D inputs (v62.0, no native coverage column), sampling requires
--coverage-derive snps_hit_1240k for priority — without it, sampling
errors out (the engine refuses to "prioritize" against an undefined
coverage column).
For the full grammar reference see the JSON
Schema; for grammar
semantics and edge cases (NaN handling, branch independence, signature
canonicalization) see the inline schema description: fields and the
CHANGELOG.
Library API (v0.4+)
aadr-subset exposes a Python API for programmatic use — no subprocess
required:
from aadr_subset import select, SubsetResult
result: SubsetResult = select("britain_iron_age.yaml", "v66.HO.aadr.PUB.anno")
print(result.genetic_ids) # list[str] of matched sample IDs
print(result.n_matched) # int
print(result.anno_version) # e.g. "v66.0"
print(result.selector_signature) # "sha256:..."
Full signature:
from pathlib import Path
from aadr_subset import select, load_selector
import aadr_resolve
result = select(
selector, # str | Path | Selector — YAML path or pre-loaded object
anno, # str | Path | AnnoFrame — .anno path or pre-loaded object
*,
allow_empty=True, # True by default (differs from CLI default of False)
allow_empty_source=False,
include_matched_criteria=False,
source_anno=None, # str | Path | AnnoFrame — cross-version source
mid_bridge=None, # str | Path — MID-rename bridge for multi-anno dedup
strict_resolve=False,
coverage_column=None, # str — override coverage column
coverage_derive=None, # str — derive proxy for class-D inputs
max_per_population=None, # int
max_per_individual=None, # int
schema_override=None, # str — force schema class detection
quiet=False,
)
Key differences from the CLI:
allow_empty=Trueby default — the API doesn't abort on zero matches; callers checkresult.n_matchedthemselves.- Warnings via
logging— the"aadr_subset"logger emits warnings (zero-match globs, v62 coverage proxy) instead of writing to stderr. Configure withlogging.getLogger("aadr_subset"). - Pre-loaded objects accepted — pass a
SelectororAnnoFramedirectly to skip repeated file I/O in loops.
Public symbols exported from aadr_subset:
select, load_selector, SubsetResult, Selector, SelectorMetadata,
SamplingSpec, AadrSubsetError, IOFailure, InvariantViolation,
SoftValidationFailure, UsageError, ValidationError.
Composing with plink2
# Materialize a cohort
aadr-subset select britain_iron_age.yaml v66.HO.aadr.PUB.anno -o cohort.ids
# Use it as a plink2 keep set
plink2 --pfile aadr_v66 \
--keep cohort.ids \
--make-pgen --out britain_iron_age_subset
select --format json produces a structured artifact suitable for
pipeline metadata logging (records the selector signature, AADR version,
schema class, and effective coverage column).
License
MIT. See LICENSE.
Release files for aadr-subset 0.4.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| aadr_subset-0.4.1.tar.gz | 73.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| aadr_subset-0.4.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size:153.1 kB
Release files / aadr_subset-0.4.1.tar.gz
| Download URL | aadr_subset-0.4.1.tar.gz |
|---|---|
| Size | 73.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
dacac47bf59066025fa5c48ab96e12d20bf4cf7e4222ba143b28e5dd44977f87
|
|
BLAKE2b-256 checksum How to use checksums |
9e504d0106da2a8b485057d7eea1de176061e54b1a8210986596da1ce90fcefb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 13, 2026.
Transparency logRelease files / aadr_subset-0.4.1-py3-none-any.whl
| Download URL | aadr_subset-0.4.1-py3-none-any.whl |
|---|---|
| Size | 80.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
74ef3bcbc8432f3a1bf1cfd9adb2285914a93e04d219d01fd0c2b4b15774f7db
|
|
BLAKE2b-256 checksum How to use checksums |
6c4450bd3d0c5426e7aa7741a24608b3b4e9abd4ea9b0dbbd46557422fccbca1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 13, 2026.
Transparency log