Skip to content

Check sample identities

varcode check-samples screens a cohort of VCFs for possible sample mix-ups, such as a tumor labelled with the wrong patient or two swapped samples. For every pair of samples it reports two separate kinds of evidence:

  • Donor: do the samples' germline SNP genotypes look like they come from the same person?
  • Tumor: do two tumor samples share somatic mutations, including precise SV junctions?

This is a screening heuristic with uncalibrated thresholds. It does not give a probability of identity, test kinship, estimate contamination, or decide which samples were swapped. "Compatible" means only that the available evidence did not distinguish the samples; relatives and identical twins can be compatible. Confirm a suspected mix-up with an informative SNP panel and a validated identity workflow.

Run a check

varcode check-samples \
  --germline normal1.vcf.gz --germline normal2.vcf.gz \
  --somatic tumor1.vcf.gz --somatic tumor2.vcf.gz \
  --assembly GRCh38 --json checks.json --tsv pairs.tsv

Each file contributes all of its sample columns. Declare what each file contains, repeating a flag as often as needed:

Flag Use for
--germline Germline calls, such as a normal sample's VCF.
--somatic Caller-reported somatic calls. A single-sample somatic VCF is treated as a tumor.
--mixed A combined germline/somatic callset.

A sample's role is never guessed from its column name. Multi-sample somatic or mixed VCFs need ##tumor_sample / ##normal_sample header lines, or roles in a manifest.

Outputs:

  • A short summary on stderr.
  • A JSON report, written to stdout unless you pass --json. It includes every pair's statuses and counts, reasons for inconclusive results, QC counts, absolute input paths and SHA-256 hashes, sample roles, the Varcode version, and all thresholds. It does not export individual genotypes or mutation lists.
  • With --tsv, a table with one row per pair: statuses, counts, concordance, somatic overlap, and flags.

Declare expected identities

To check samples against what you expect, list them in a CSV or .tsv manifest. It selects exact sample columns and records which donor and tumor each should belong to. Paths are relative to the manifest, not the shell's working directory:

path,sample,kind,role,label,donor_id,tumor_id,normal_sample,assembly
normal.vcf.gz,N,germline,normal,normal-A,patient-A,,,GRCh38
paired.vcf.gz,N,mixed,normal,paired-normal-A,patient-A,,,GRCh38
paired.vcf.gz,T,mixed,tumor,tumor-A,patient-A,tumor-A,N,GRCh38
recurrence.vcf.gz,T,somatic,tumor,recurrence-A,patient-A,tumor-A,,GRCh38
varcode check-samples --manifest samples.csv \
  --json checks.json --tsv pairs.tsv --fail-on-mismatch
  • Only path and sample are required. kind defaults to germline.
  • label names the sample in reports and must be unique. It defaults to absolute-path::sample.
  • The same file and sample can't be listed twice.
  • A tumor's normal_sample names a column in the same VCF, even if that normal isn't selected for output. A single declared normal is paired automatically; with several normals, name one explicitly.
  • Roles are never guessed from words such as TUMOR or NORMAL.

Expected identities never change the scores. They only add flags where the results contradict your expectations.

Reading the results

Each pair gets a donor status and a tumor status. An inconclusive status comes with reasons in the JSON report.

Donor status Meaning
compatible Genotypes agree closely enough that the samples may come from the same person. Relatives and identical twins can also be compatible.
discordant Genotypes disagree enough to suggest different people.
inconclusive Too little usable shared evidence, a result between the compatible and discordant thresholds, or different genome builds.
Tumor status Meaning
shared_somatic_support Both samples are tumors, and they share enough somatic alleles to suggest a common tumor.
low_overlap Both tumors have enough somatic alleles, but few are shared. On its own, this does not show a different tumor or donor.
inconclusive One sample isn't a tumor, a tumor has too few somatic alleles, or the genome builds differ.

Somatic overlap cannot prove that two samples came from the same tumor. Recurrent drivers, germline leakage, assay overlap, purity, copy number, and caller sensitivity can all raise or lower it.

When you've declared expected identities, these flags mark results to review:

Flag Meaning
expected_donor_mismatch Discordant donor evidence despite equal donor or tumor IDs, or a declared tumor/normal pairing.
unexpected_donor_compatibility Compatible evidence despite different donor IDs; review potential swaps, relatedness and evidence limitations.
expected_tumor_low_overlap Equal tumor IDs but low somatic overlap; review coverage, purity, calling and tumor evolution.
paired_normal_has_different_donor_label The declared pairing and donor labels disagree.
unexpected_tumor_overlap Shared somatic support despite different tumor IDs; review shared origins, recurrent mutations or possible duplicate/mislabelled samples.
same_tumor_has_different_donor_labels Equal tumor IDs conflict with different donor IDs.

Exit codes: with --fail-on-mismatch, the command returns 1 only for expected_donor_mismatch. A completed analysis otherwise returns 0, even when results are inconclusive; invalid inputs return 2. Without declared expectations, the report gives pairwise evidence and never declares a swap.

Python API

from varcode import (
    SampleCheckConfig, SampleSpec, load_vcf_samples,
    compare_samples, check_sample_identity,
)

config = SampleCheckConfig()
normals = load_vcf_samples("normal.vcf.gz", assembly="GRCh38", config=config)
paired = load_vcf_samples(
    "paired.vcf.gz", kind="mixed", assembly="GRCh38", config=config,
    samples=[
        SampleSpec("N", role="normal", donor_id="patient-A"),
        SampleSpec("T", role="tumor", donor_id="patient-A", normal_sample="N"),
    ],
)
pair = compare_samples(paired[0], paired[1])
report = check_sample_identity(normals + paired)

load_vcf_samples returns SampleFingerprint objects. compare_samples and check_sample_identity return JSON-compatible dictionaries. Use the same config when loading every sample.

Requirements and limits:

  • No transcript annotation or reference sequence download is needed.
  • Local VCF and gzip-compressed VCF files are supported, not BCF or remote URLs.
  • Assemblies must be GRCh37/hg19/b37 or GRCh38/hg38/b38. A recognized ##reference header is used unless you supply the assembly explicitly; contradictions are errors.
  • Samples on different builds give inconclusive comparisons. There is no liftover.

How the evidence is judged

The numbers below are defaults; each can be changed.

Donor checks

Which genotypes count. Only complete diploid genotypes at individually represented SNPs on autosomes (chromosomes 1–22; chr1 and 1 are equivalent). Genotypes are compared as actual bases, so phase and ALT order don't matter.

  • The record FILTER and sample FT must be PASS or missing.
  • Depth must be at least 10 and GQ at least 20 where available. Complete AD can supply depth when DP is absent. Missing values are counted and accepted by default; set require_quality=True to require both.
  • Missing records, partial or haploid GTs, gVCF blocks, indels, and SVs don't count. A missing call is never assumed to be homozygous reference.
  • An individually called SNP stays usable when an uncalled <NON_REF> or indel ALT is also listed; a GT that actually calls such an allele is excluded.
  • Loci with conflicting passing SNP records are excluded. Reference-base disagreements between samples are counted separately and excluded.
  • Malformed GT indexes, non-finite or negative quality values, and AD/AF with the wrong number of values stop the analysis with a file and line number.

Minimum evidence. Without all of these, the status is inconclusive:

  • 100 shared callable SNPs;
  • 20 non-reference SNPs in each sample;
  • at least two of the three genotype categories (homozygous reference, heterozygous, homozygous ALT) in each sample;
  • SNPs spread over at least 20 distinct 100 kb genomic bins.

These are screening safeguards, not a claim that the sites are independent or population-informative.

Decision. IBS0 is the fraction of sites where the two genotypes share no allele.

Comparison Score Compatible Discordant
Two non-tumor samples Exact genotype concordance score ≥ 0.98 and IBS0 ≤ 0.01 score < 0.90 or IBS0 ≥ 0.05
Either sample is a tumor Allele sharing (1 - IBS0) score ≥ 0.98 and IBS0 ≤ 0.01 score < 0.90 or IBS0 ≥ 0.05

Anything in between is inconclusive.

Comparisons involving a tumor. Loss of heterozygosity can turn a heterozygous genotype into a homozygous one, so exact concordance would penalize a true match. Allele sharing tolerates that change; both measures are reported. These comparisons also require 20 sites that are homozygous in both samples, and 20 explicit homozygous-reference calls in each sample at shared sites. Without reference calls, two variant-only VCFs automatically share an ALT allele at every overlapping site, which would make the test meaningless. Such data can still contribute somatic evidence. A normal column containing only 0/0 calls at somatic sites also stays inconclusive for identity.

Sampling. Up to 50,000 SNP loci per sample are kept, chosen by a deterministic SHA-256 bottom-k sketch keyed only by assembly and position. This bounds memory and doesn't depend on genotypes or record order. Comparisons report counts for the loci actually retained, not whole-genome estimates; omitted and conflicting loci are listed in QC.

Somatic checks

Which alleles count. Only tumor samples contribute somatic alleles.

  • The allele must be present in a complete GT. Without a called GT, it needs at least 3 ALT reads in AD, VAF at least 0.03, and depth at least 10. GT takes precedence over contradictory AD. AF is kept for VAF comparison, but AF alone does not establish a call.
  • The allele must also be shown to be somatic:
    • With a declared matched normal, the allele must be absent from the normal's GT with adequate normal depth and, if AD is available, at most 1 ALT read and VAF at most 0.02. Missing or filtered normal evidence cannot establish absence.
    • Otherwise, the input must be declared somatic or the record must carry a VCF SOMATIC flag. Unlabelled non-reference alleles in a mixed tumor VCF are not assumed to be somatic.
  • QC distinguishes paired-normal contrasts from caller-reported evidence.

Matching alleles. Small alleles are minimally trimmed, without repeat left-alignment. Precise, sequence-resolved SVs use the SV comparison normalizer, including reciprocal-BND deduplication. Imprecise, unknown-insertion, and unsupported SVs are excluded with counts, and nearby breakpoints are not treated as identical. As a result, equivalent repeats or caller representations may undercount overlap. Explicit sequence alleles and symbolic SVs use separate keys; comparing across those representations requires the dedicated SV comparison workflow.

Decision. The report includes shared and total counts, the shared fraction in each direction, Jaccard similarity, and the overlap coefficient (shared / min(count_a, count_b)). The overlap coefficient tolerates branching evolution and callsets of different sizes.

Tumor status Requirement
shared_somatic_support At least 10 alleles in each tumor, at least 5 shared, and an overlap coefficient of at least 0.20
low_overlap At least 10 alleles in each tumor, but fewer than 5 shared or an overlap coefficient below 0.20
inconclusive Fewer than 10 alleles in either tumor

VAF Pearson correlation is descriptive only and never determines status. It requires at least 5 shared VAFs with nonzero variance. Repeated alleles count once, and conflicting duplicate VAFs are left out of the correlation. A sample with more than 100,000 somatic alleles raises an explicit error rather than being silently truncated. Coverage and callable regions are not modeled, and population allele frequencies are not used.

Configure and reproduce

Every threshold is a SampleCheckConfig field and appears in the report's config. The CLI accepts the same names in a JSON object, for example:

{"require_quality": true, "min_shared_snps": 200, "max_snps": 100000}

Pass it with --config thresholds.json. Changing thresholds changes the evidence requirements; the defaults have not been calibrated on an independent clinical cohort. Synthetic ground-truth VCF generators and regression cases are in tests/test_sample_identity.py.

The distinction between genotype concordance and tumor-specific allele-fraction changes is informed by the primary Somalier paper and its cancer concordance guidance. NGSCheckMate illustrates a validated SNP-panel allele-fraction approach. This implementation does not reproduce their calibrated models, panels, or accuracy claims. Input semantics follow the VCF specification.

API reference

varcode.sample_identity

Heuristic donor concordance and somatic overlap, without absent-call imputation.

See docs/sample_identity.md for assumptions, thresholds and limitations. These checks nominate mix-ups for review; they do not certify identity.

SampleCheckConfig(min_depth: int = 10, min_gq: int = 20, require_quality: bool = False, min_shared_snps: int = 100, min_nonreference_snps: int = 20, min_homozygous_snps: int = 20, min_tumor_hom_reference_snps: int = 20, min_genomic_bins: int = 20, genomic_bin_size: int = 100000, max_snps: int = 50000, max_somatic_variants: int = 100000, compatible_concordance: float = 0.98, discordant_concordance: float = 0.9, max_compatible_ibs0: float = 0.01, min_discordant_ibs0: float = 0.05, min_somatic_variants: int = 10, min_shared_somatic: int = 5, min_somatic_overlap: float = 0.2, min_tumor_alt_reads: int = 3, min_tumor_vaf: float = 0.03, max_normal_alt_reads: int = 1, max_normal_vaf: float = 0.02) dataclass

Configurable, uncalibrated screening thresholds (all reported in output).

Missing DP/GQ are counted but accepted unless require_quality is true. SNPs use a deterministic, genotype-independent bottom-k locus sketch. The somatic limit raises an error instead of silently truncating evidence.

SampleSpec(sample: str, role: Optional[str] = None, label: Optional[str] = None, donor_id: Optional[str] = None, tumor_id: Optional[str] = None, normal_sample: Optional[str] = None) dataclass

Select a VCF column and optionally declare its role and expected identity.

PARAMETER DESCRIPTION
sample

Exact VCF column name. Names are never interpreted as biological roles.

TYPE: str

role

germline, normal or tumor. Ambiguous somatic/mixed inputs require roles here or in tumor_sample/normal_sample header metadata.

TYPE: str DEFAULT: None

label

Unique report identifier; defaults to absolute path plus sample name.

TYPE: str DEFAULT: None

donor_id

Expected identities used to flag contradictions, not scoring inputs.

TYPE: str DEFAULT: None

tumor_id

Expected identities used to flag contradictions, not scoring inputs.

TYPE: str DEFAULT: None

normal_sample

Matched normal column in the same VCF (may be unselected for output).

TYPE: str DEFAULT: None

SampleFingerprint(label: str, sample: str, path: str, sha256: str, assembly: str, kind: str, role: str, config: SampleCheckConfig, donor_id: Optional[str] = None, tumor_id: Optional[str] = None, normal_sample: Optional[str] = None, snps: dict = dict(), somatic: dict = dict(), qc: Counter = Counter()) dataclass

Loaded evidence with input provenance and exclusion counts.

Construct with :func:load_vcf_samples. snps maps (chromosome, position) to (reference base, sorted diploid base genotype), or None for a conflicting locus. somatic maps normalized allele keys to VAF or None. Report serialization omits these potentially large, identifying maps.

summary()

Return JSON-compatible metadata and QC, excluding genotypes.

Source code in varcode/sample_identity.py
def summary(self):
    """Return JSON-compatible metadata and QC, excluding genotypes."""
    result = {key: getattr(self, key) for key in (
        "label", "sample", "path", "sha256", "assembly", "kind", "role",
        "donor_id", "tumor_id", "normal_sample")}
    result.update(qc=dict(self.qc), retained_snps=sum(v is not None for v in self.snps.values()),
                  somatic_variants=len(self.somatic))
    return result

load_vcf_samples(path, *, kind='germline', assembly=None, samples=None, config=None)

Stream a local VCF/VCF.gz into selected sample fingerprints.

PARAMETER DESCRIPTION
path

Local VCF, optionally gzip compressed. No annotation data is needed.

TYPE: str or Path

kind

germline, somatic (caller-reported somatic records), or mixed. Mixed tumor calls require SOMATIC or an explicit matched-normal contrast.

TYPE: str DEFAULT: 'germline'

assembly

GRCh37/hg19/b37 or GRCh38/hg38. Required if the header is unrecognized. An explicit value may not contradict a recognized header reference.

TYPE: str DEFAULT: None

samples

Selection, roles and expectations. Default: all VCF sample columns.

TYPE: iterable of SampleSpec DEFAULT: None

config

Shared loading and scoring thresholds for the cohort.

TYPE: SampleCheckConfig DEFAULT: None

RETURNS DESCRIPTION
list of SampleFingerprint

One entry per selected column, including samples with no usable calls.

Source code in varcode/sample_identity.py
def load_vcf_samples(path, *, kind="germline", assembly=None, samples=None, config=None):
    """Stream a local VCF/VCF.gz into selected sample fingerprints.

    Parameters
    ----------
    path : str or pathlib.Path
        Local VCF, optionally gzip compressed. No annotation data is needed.
    kind : str
        ``germline``, ``somatic`` (caller-reported somatic records), or ``mixed``.
        Mixed tumor calls require SOMATIC or an explicit matched-normal contrast.
    assembly : str, optional
        GRCh37/hg19/b37 or GRCh38/hg38. Required if the header is unrecognized.
        An explicit value may not contradict a recognized header reference.
    samples : iterable of SampleSpec, optional
        Selection, roles and expectations. Default: all VCF sample columns.
    config : SampleCheckConfig, optional
        Shared loading and scoring thresholds for the cohort.

    Returns
    -------
    list of SampleFingerprint
        One entry per selected column, including samples with no usable calls.
    """
    from ._sample_vcf import read_samples
    return read_samples(path, kind, assembly, samples, config or SampleCheckConfig())

compare_samples(a, b, *, config=None)

Return pairwise donor/tumor evidence and expectation flags as a dict.

Both fingerprints must have been loaded with the same config. Different assemblies yield inconclusive results. flags are review prompts; only expected_donor_mismatch sets identity_conflict to true.

Source code in varcode/sample_identity.py
def compare_samples(a, b, *, config=None):
    """Return pairwise donor/tumor evidence and expectation flags as a dict.

    Both fingerprints must have been loaded with the same config. Different
    assemblies yield inconclusive results. ``flags`` are review prompts;
    only ``expected_donor_mismatch`` sets ``identity_conflict`` to true.
    """
    config = config or a.config
    if a.config != config or b.config != config:
        raise ValueError("Load all samples with the comparison config")
    if a.assembly != b.assembly:
        germline = dict(status="inconclusive", reasons=["assembly_mismatch"])
        somatic = dict(status="inconclusive", reasons=["assembly_mismatch"])
    else:
        germline, somatic = _germline(a, b, config), _somatic(a, b, config)
    paired = a.path == b.path and (a.normal_sample == b.sample or b.normal_sample == a.sample)
    same_donor = a.donor_id == b.donor_id if a.donor_id and b.donor_id else None
    same_tumor = a.tumor_id == b.tumor_id if a.tumor_id and b.tumor_id else None
    flags = []
    if paired and same_donor is False:
        flags.append("paired_normal_has_different_donor_label")
    if same_tumor is True and same_donor is False:
        flags.append("same_tumor_has_different_donor_labels")
    if (same_donor is True or paired or same_tumor is True) and germline["status"] == "discordant":
        flags.append("expected_donor_mismatch")
    if same_donor is False and germline["status"] == "compatible":
        flags.append("unexpected_donor_compatibility")
    if same_tumor is True and somatic["status"] == "low_overlap":
        flags.append("expected_tumor_low_overlap")
    if same_tumor is False and somatic["status"] == "shared_somatic_support":
        flags.append("unexpected_tumor_overlap")
    return dict(sample_a=a.label, sample_b=b.label, expected_same_donor=same_donor,
                paired_tumor_normal=paired, expected_same_tumor=same_tumor,
                germline=germline, somatic=somatic, flags=flags,
                identity_conflict="expected_donor_mismatch" in flags)

check_sample_identity(samples, *, config=None)

Check every unordered pair and return a JSON-compatible cohort report.

At least two uniquely labelled samples are required. No automatic renaming or definitive swap assignment is performed. Config, sample QC, provenance, all pairs and heuristic limitations are included for reproducibility.

Source code in varcode/sample_identity.py
def check_sample_identity(samples, *, config=None):
    """Check every unordered pair and return a JSON-compatible cohort report.

    At least two uniquely labelled samples are required. No automatic renaming
    or definitive swap assignment is performed. Config, sample QC, provenance,
    all pairs and heuristic limitations are included for reproducibility.
    """
    from .version import __version__
    samples = list(samples)
    if len(samples) < 2:
        raise ValueError("At least two samples are required")
    if len({s.label for s in samples}) != len(samples):
        raise ValueError("Sample labels must be unique across the cohort")
    if len({(s.path, s.sample) for s in samples}) != len(samples):
        raise ValueError("The same VCF sample was supplied more than once")
    config = config or samples[0].config
    pairs = [compare_samples(a, b, config=config) for a, b in combinations(samples, 2)]
    return dict(schema_version=1, varcode_version=__version__, config=asdict(config),
                samples=[s.summary() for s in samples], pairs=pairs,
                identity_conflicts=sum(p["identity_conflict"] for p in pairs),
                limitations=["Uncalibrated screening heuristics, not identity probabilities or proof.",
                             "Absent variants are unknown, not reference calls.",
                             "Relatives, twins, LOH, contamination and assay differences can confound results.",
                             "Low somatic overlap does not establish a different donor or tumor."])