08 · VCF Mutation Analysis

Mutation frequency, Shannon entropy heatmaps, and tissue-type comparisons

genomics
virology
VCF
mutation
entropy

Per-sample VCF files from a variant caller. Coverage filtering, Shannon entropy per position, mutation frequency bar charts and entropy heatmaps per tissue type, Kruskal-Wallis comparison across tissue sources.

Overview

Item Details
Input Directory of .vcf files: aa_change_{ID}-{Source}_{Segment}_{Threshold}.vcf
Key packages vcfpy, pandas, numpy, scipy, seaborn, matplotlib
Statistics Shannon entropy (natural log) · Kruskal–Wallis with Benjamini–Hochberg FDR
Output Mutation bar charts · entropy heatmaps (SVG) · frequency tables · KW results CSV
Download template.ipynb

Edit SEGMENT_CONFIG to match your pathogen’s genome (total length, CDS boundaries, AA count). TISSUE_TYPES controls which sources are included in statistical comparisons.

template.ipynb is generated from this file with quarto convert template.qmd — edit the .qmd and re-convert rather than editing the notebook, so the two cannot drift apart.

← Gallery Download .ipynb


User Configuration

Code
# ── USER CONFIGURATION ────────────────────────────────────────────────────────

# Directory containing VCF files (relative to this file)
VCF_DIR = "data/aa_change_vcf"

# VCF filename format (regex). Named groups: id, source, segment, threshold
# Default: aa_change_{id}-{source}_{segment}_{threshold}.vcf
VCF_FILENAME_PATTERN = r"aa_change_(?P<id>[^-]+)-(?P<source>[^_]+)_(?P<segment>[SM])_(?P<threshold>\d+pct)\.vcf"

# Map threshold labels in filenames to display strings
THRESHOLD_LABEL_MAP = {"5pct": "5%", "2pct": "2%", "1pct": "1%"}

# Minimum read depth to retain a variant (coverage QC)
DEPTH_THRESHOLD = 500

# Tissue / source types to INCLUDE in the final analysis
# (all others present in VCF filenames will be loaded but filtered out)
TISSUE_TYPES = ["Lung", "Saliva", "Urine"]

# Segment geometry — edit to match your pathogen's genome.
# The CDS must be codon-aligned: (cds_end - cds_start + 1) == aa_length * 3.
# That is checked below, because a CDS that is not a whole number of codons
# means one of the three numbers is wrong.
SEGMENT_CONFIG = {
    "S": {
        "length": 1902,       # total nucleotide length
        "cds_start": 250,     # first CDS position (1-based)
        "cds_end": 1329,      # last CDS position (1-based, inclusive)
        "aa_length": 360,     # total amino acids in CDS
    },
    "M": {
        "length": 3675,
        "cds_start": 52,
        "cds_end": 3456,
        "aa_length": 1135,
    },
}

# VAF thresholds to display in bar charts (ordered low → high)
VAF_THRESHOLDS = ["1%", "2%", "5%"]

# Colors and transparency for each VAF threshold in bar charts
VAF_COLORS = {
    "5%": "#3594cc",   # Blue
    "2%": "#ea801c",   # Orange
    "1%": "#a00000",   # Dark red
}
VAF_ALPHA = {"5%": 0.3, "2%": 0.6, "1%": 0.9}

# Family-wise error rate for the Benjamini–Hochberg correction on the
# Kruskal–Wallis tests
FDR_ALPHA = 0.05

# Output directory for SVG plots and derived tables (relative to this file)
OUTPUT_DIR = "Plots"

# ─────────────────────────────────────────────────────────────────────────────

Setup

Code
import os
import re
import warnings

import numpy as np
import pandas as pd
import vcfpy
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import entropy, kruskal, false_discovery_control

# Narrow suppression only: seaborn's violinplot still passes `palette` without
# `hue` internally on some versions. Everything else stays visible on purpose.
warnings.filterwarnings("ignore", category=FutureWarning, module="seaborn")

# ── CDS geometry sanity check ────────────────────────────────────────────────
# Mutations_per_100aa and the coding-region filter both assume the CDS is a whole
# number of codons; a mismatch here means the per-100aa rate is silently wrong.
for _seg, _cfg in SEGMENT_CONFIG.items():
    _cds_len = _cfg["cds_end"] - _cfg["cds_start"] + 1
    assert _cds_len == _cfg["aa_length"] * 3, (
        f"Segment {_seg}: CDS spans {_cds_len} nt, but aa_length="
        f"{_cfg['aa_length']} implies {_cfg['aa_length'] * 3} nt. "
        f"Fix cds_start / cds_end / aa_length in SEGMENT_CONFIG."
    )
    assert _cfg["cds_end"] <= _cfg["length"], (
        f"Segment {_seg}: cds_end ({_cfg['cds_end']}) exceeds the segment "
        f"length ({_cfg['length']})."
    )
    # Cached so the mutation-rate denominators cannot drift from the config.
    _cfg["cds_length"] = _cds_len

# ── Global plot style ─────────────────────────────────────────────────────────
# Reproducible SVG output: matplotlib stamps a wall-clock date and randomly
# generated element IDs into every SVG, so re-rendering an unchanged figure
# produced a large but meaningless git diff. A fixed salt and a fixed
# SOURCE_DATE_EPOCH make repeat renders byte-identical.
os.environ.setdefault("SOURCE_DATE_EPOCH", "1735689600")   # 2025-01-01 UTC
plt.rcParams["svg.hashsalt"] = "lab-bioinfo-templates"

plt.rcParams.update({
    'axes.titlesize': 12,
    'axes.labelsize': 10,
    'xtick.labelsize': 10,
    'ytick.labelsize': 10,
    'legend.fontsize': 10,
})
pd.set_option('display.width', 1000)

os.makedirs(OUTPUT_DIR, exist_ok=True)
print(f"Output will be saved to: {os.path.abspath(OUTPUT_DIR)}")
Output will be saved to: /home/runner/work/lab-bioinfo-templates/lab-bioinfo-templates/templates/08_vcf-mutation-analysis/Plots

Load & Parse VCF Files

Code
pattern = re.compile(VCF_FILENAME_PATTERN)

records = []
vcf_files = [f for f in os.listdir(VCF_DIR) if f.endswith(".vcf")]
print(f"Found {len(vcf_files)} VCF files in {VCF_DIR}")

for fname in vcf_files:
    m = pattern.match(fname)
    if not m:
        print(f"  Skipping (name mismatch): {fname}")
        continue

    sample_id  = m.group("id")
    source     = m.group("source")
    segment    = m.group("segment")
    thresh_raw = m.group("threshold")
    threshold  = THRESHOLD_LABEL_MAP.get(thresh_raw, thresh_raw)

    fpath = os.path.join(VCF_DIR, fname)
    try:
        reader = vcfpy.Reader.from_path(fpath)
        for rec in reader:
            if not rec.ALT:
                continue
            alt_allele = rec.ALT[0].value if rec.ALT else "."
            aa_change  = rec.INFO.get("AA_CHANGE", "N/A")
            if isinstance(aa_change, list):
                aa_change = aa_change[0]

            sample_call = (
                rec.call_for_sample["SAMPLE"]
                if "SAMPLE" in rec.call_for_sample
                else rec.calls[0]
            )
            ad = sample_call.data.get("AD", [0, 0])
            dp = sample_call.data.get("DP", 0)

            records.append({
                "Position":          rec.POS,
                "Reference_Allele":  rec.REF,
                "Alternate_Allele":  alt_allele,
                "Quality":           rec.QUAL,
                "Amino_Acid_Change": aa_change,
                "Allelic_Depth_Ref": ad[0] if len(ad) > 0 else 0,
                "Allelic_Depth_Alt": ad[1] if len(ad) > 1 else 0,
                "Total_Depth":       dp,
                "Source":            source,
                "Segment":           segment,
                "Variant_Threshold": threshold,
                "ID":                sample_id,
            })
    except Exception as e:
        print(f"  Error reading {fname}: {type(e).__name__}: {e}")

combined_df = pd.DataFrame(records)
print(f"Total records parsed: {len(combined_df)}")

if combined_df.empty:
    raise RuntimeError(
        f"No VCF records were parsed from '{os.path.abspath(VCF_DIR)}'. "
        f"Check that simulate_data.py ran successfully and that files match "
        f"VCF_FILENAME_PATTERN. Files found: {vcf_files[:5]}"
    )

REQUIRED_COLUMNS = [
    "Position", "Amino_Acid_Change", "Allelic_Depth_Ref", "Allelic_Depth_Alt",
    "Total_Depth", "Source", "Segment", "Variant_Threshold", "ID",
]
_missing = set(REQUIRED_COLUMNS) - set(combined_df.columns)
if _missing:
    raise ValueError(f"Parsed variant table is missing column(s): {sorted(_missing)}")

unknown_segments = sorted(set(combined_df["Segment"]) - set(SEGMENT_CONFIG))
if unknown_segments:
    raise ValueError(
        f"VCF filenames reference segment(s) absent from SEGMENT_CONFIG: "
        f"{unknown_segments}. Add them, or narrow VCF_FILENAME_PATTERN."
    )

combined_df.head()
Found 108 VCF files in data/aa_change_vcf
Total records parsed: 881
Position Reference_Allele Alternate_Allele Quality Amino_Acid_Change Allelic_Depth_Ref Allelic_Depth_Alt Total_Depth Source Segment Variant_Threshold ID
0 128 G C 46.8 N/A 681 36 717 Urine S 5% SMPL006
1 285 T C 40.8 Ser200Pro 1709 366 2075 Urine S 5% SMPL006
2 591 T A 30.5 Arg412Ser 514 33 547 Urine S 5% SMPL006
3 652 C T 36.3 Val52Ile 1824 238 2062 Urine S 5% SMPL006
4 736 A G 51.1 Met120Val 574 53 627 Urine S 5% SMPL006

Quality Control: Coverage Filtering

Code
pre_filter = len(combined_df)
combined_df = combined_df[combined_df["Total_Depth"] >= DEPTH_THRESHOLD].copy()
print(f"Retained {len(combined_df)} / {pre_filter} records (depth ≥ {DEPTH_THRESHOLD}x)")

# Fix Allelic_Depth_Ref when reported as 0 (some callers omit it)
mask = combined_df["Allelic_Depth_Ref"] == 0
combined_df.loc[mask, "Allelic_Depth_Ref"] = (
    combined_df.loc[mask, "Total_Depth"] - combined_df.loc[mask, "Allelic_Depth_Alt"]
)

combined_df["VAF"] = combined_df["Allelic_Depth_Alt"] / combined_df["Total_Depth"]

# Save the combined dataset. This is a derived table, so it belongs beside the
# other outputs — data/ holds only the inputs the template reads.
mutation_table_path = os.path.join(OUTPUT_DIR, "mutation_df.csv")
combined_df.to_csv(mutation_table_path, index=False)
print(f"Saved: {mutation_table_path}")
combined_df.describe()
Retained 881 / 881 records (depth ≥ 500x)
Saved: Plots/mutation_df.csv
Position Quality Allelic_Depth_Ref Allelic_Depth_Alt Total_Depth VAF
count 881.000000 881.000000 881.000000 881.000000 881.000000 881.000000
mean 1420.015891 44.650965 1685.986379 90.973893 1776.960272 0.051504
std 953.757726 8.466886 681.680939 91.886902 714.294141 0.043666
min 5.000000 30.100000 424.000000 6.000000 505.000000 0.009498
25% 661.000000 37.300000 1101.000000 35.000000 1152.000000 0.023283
50% 1293.000000 44.300000 1711.000000 60.000000 1795.000000 0.034854
75% 1931.000000 51.900000 2267.000000 110.000000 2379.000000 0.064785
max 3648.000000 60.000000 2960.000000 566.000000 3000.000000 0.198986

Mutation Distribution Bar Charts

Each bar spans one variant call position; transparency encodes VAF threshold.

Code
# Bar widths per segment (in nucleotides)
bar_widths = {"S": 9.0, "M": 14.0}

for source in TISSUE_TYPES:
    src_df = combined_df[combined_df["Source"] == source]
    if src_df.empty:
        continue

    fig, axes = plt.subplots(1, 2, figsize=(14, 3), constrained_layout=True)

    for ax, seg in zip(axes, ["S", "M"]):
        cfg   = SEGMENT_CONFIG[seg]
        seg_df = src_df[src_df["Segment"] == seg]

        for thresh in VAF_THRESHOLDS:
            t_df = seg_df[seg_df["Variant_Threshold"] == thresh]
            if t_df.empty:
                continue
            ax.bar(
                t_df["Position"],
                t_df["VAF"],
                width=bar_widths[seg],
                color=VAF_COLORS[thresh],
                alpha=VAF_ALPHA[thresh],
                label=thresh,
            )

        ax.set_xlim(1, cfg["length"])
        # No fixed y ceiling: a hardcoded limit clips any variant above it off
        # the chart without saying so. Let the data set the top.
        ax.set_ylim(0, max(0.05, src_df["VAF"].max() * 1.1))
        ax.set_xlabel("Genomic position (nt)")
        ax.set_ylabel("Variant allele frequency (VAF)")
        ax.set_title(f"{seg} segment — {source}")
        ax.axvspan(cfg["cds_start"], cfg["cds_end"], alpha=0.06, color="grey", label="CDS")
        ax.legend(loc="upper right", fontsize=8)

    fig.savefig(os.path.join(OUTPUT_DIR, f"mutation_map_{source}.svg"),
                format="svg", bbox_inches="tight")
    plt.show()
    print(f"Saved: mutation_map_{source}.svg")

Saved: mutation_map_Lung.svg

Saved: mutation_map_Saliva.svg

Saved: mutation_map_Urine.svg

Shannon Entropy

Entropy (natural log base) calculated from reference and alternate allele frequencies.

Code
# Allele frequencies
combined_df["Freq_Ref"] = combined_df["Allelic_Depth_Ref"] / combined_df["Total_Depth"]
combined_df["Freq_Alt"] = combined_df["Allelic_Depth_Alt"] / combined_df["Total_Depth"]

def calculate_entropy(row):
    freqs = np.nan_to_num([row["Freq_Ref"], row["Freq_Alt"]], nan=0.0)
    return entropy(freqs, base=np.e)   # Shannon entropy, natural log

combined_df["Entropy"] = combined_df.apply(calculate_entropy, axis=1)
print(f"Entropy range: {combined_df['Entropy'].min():.4f}{combined_df['Entropy'].max():.4f}")
combined_df[["Position", "Segment", "Source", "Variant_Threshold", "Entropy"]].head(10)
Entropy range: 0.0537 – 0.4990
Position Segment Source Variant_Threshold Entropy
0 128 S Urine 5% 0.199131
1 285 S Urine 5% 0.465868
2 591 S Urine 5% 0.227872
3 652 S Urine 5% 0.357703
4 736 S Urine 5% 0.289695
5 997 S Urine 5% 0.256017
6 175 S Lung 2% 0.143310
7 290 S Lung 2% 0.209688
8 398 S Lung 2% 0.256734
9 405 S Lung 2% 0.126023

Entropy Heatmaps

Rows = tissue source, columns = genomic position. Colour = mean Shannon entropy.

Code
custom_cmap = sns.color_palette("Blues", as_cmap=True)

for seg in SEGMENT_CONFIG:
    seg_df = combined_df[
        (combined_df["Segment"] == seg) &
        (combined_df["Source"].isin(TISSUE_TYPES))
    ]

    # Pivot: mean entropy per source × position
    pivot = (
        seg_df
        .groupby(["Source", "Position"])["Entropy"]
        .mean()
        .unstack(fill_value=0)
    )

    if pivot.empty:
        print(f"No data for {seg} segment — skipping heatmap")
        continue

    g = sns.clustermap(
        pivot,
        cmap=custom_cmap,
        row_cluster=True,
        col_cluster=False,
        figsize=(12, max(2, len(pivot) * 0.8 + 1)),
        linewidths=0,
        cbar_pos=(1.05, 0.2, 0.03, 0.6),
        xticklabels=False,
    )
    g.ax_heatmap.set_xlabel("Genomic position (nt)")
    g.ax_heatmap.set_ylabel("Tissue source")
    g.fig.suptitle(f"{seg} segment — Shannon Entropy by Source", y=1.02, fontsize=12)

    out_svg = os.path.join(OUTPUT_DIR, f"entropy_map_{seg}.svg")
    g.fig.savefig(out_svg, format="svg", bbox_inches="tight")
    plt.show()
    print(f"Saved: {out_svg}")

Saved: Plots/entropy_map_S.svg

Saved: Plots/entropy_map_M.svg

Mutation Frequency per Sample

Normalised to mutations per 1,000 nt of CDS (nucleotide level) and per 100 amino acids (non-synonymous only). Both numerators are restricted to coding positions, so both denominators are coding lengths.

Code
def filter_coding(df):
    """Retain only CDS positions for each segment."""
    masks = []
    for seg, cfg in SEGMENT_CONFIG.items():
        m = (
            (df["Segment"] == seg) &
            (df["Position"].between(cfg["cds_start"], cfg["cds_end"]))
        )
        masks.append(m)
    combined_mask = masks[0]
    for mask in masks[1:]:
        combined_mask = combined_mask | mask
    return df[combined_mask]


coding_df  = filter_coding(combined_df)
analysis_df = coding_df[coding_df["Source"].isin(TISSUE_TYPES)].copy()

freq_rows = []
for (sample_id, source, seg, thresh), grp in analysis_df.groupby(
    ["ID", "Source", "Segment", "Variant_Threshold"]
):
    cfg = SEGMENT_CONFIG[seg]
    n_total  = len(grp)
    n_nonsyn = (grp["Amino_Acid_Change"] != "N/A").sum()

    freq_rows.append({
        "ID":                   sample_id,
        "Source":               source,
        "Segment":              seg,
        "Threshold":            thresh,
        # Numerator counts CDS variants only (filter_coding above), so the
        # denominator must be the CDS length — dividing by the full segment
        # length understates every rate, and by a different factor per segment.
        "Mutations_per_1000bp": (n_total / cfg["cds_length"]) * 1000,
        "Mutations_per_100aa":  (n_nonsyn / cfg["aa_length"]) * 100,
    })

freq_df = pd.DataFrame(freq_rows)
print(freq_df.groupby(["Segment", "Threshold"])[
    ["Mutations_per_1000bp", "Mutations_per_100aa"]
].mean().round(2))
freq_df.head()
                   Mutations_per_1000bp  Mutations_per_100aa
Segment Threshold                                           
M       1%                         3.30                 0.85
        2%                         1.91                 0.48
        5%                         1.22                 0.31
S       1%                         6.58                 1.73
        2%                         3.96                 1.02
        5%                         2.31                 0.56
ID Source Segment Threshold Mutations_per_1000bp Mutations_per_100aa
0 SMPL001 Lung M 1% 4.111601 1.145374
1 SMPL001 Lung M 2% 2.055800 0.528634
2 SMPL001 Lung M 5% 1.468429 0.352423
3 SMPL001 Lung S 1% 6.481481 1.944444
4 SMPL001 Lung S 2% 2.777778 0.555556

Mutation Frequency Violin Plots

Code
for seg in SEGMENT_CONFIG:
    seg_freq = freq_df[freq_df["Segment"] == seg]
    if seg_freq.empty:
        continue

    fig, axes = plt.subplots(1, 2, figsize=(10, 4), constrained_layout=True)
    fig.suptitle(f"{seg} segment — Mutation Frequency by Source", fontsize=12)

    metrics = [
        ("Mutations_per_1000bp", "Mutations per 1,000 bp"),
        ("Mutations_per_100aa",  "Non-syn. mutations per 100 aa"),
    ]
    for ax, (col, label) in zip(axes, metrics):
        sns.violinplot(
            data=seg_freq[seg_freq["Source"].isin(TISSUE_TYPES)],
            x="Source", y=col, hue="Threshold",
            palette=VAF_COLORS, order=TISSUE_TYPES,
            inner="quartile", ax=ax,
        )
        ax.set_xlabel("Tissue type")
        ax.set_ylabel(label)
        ax.set_title(label)

    fig.savefig(os.path.join(OUTPUT_DIR, f"mutation_freq_{seg}.svg"),
                format="svg", bbox_inches="tight")
    plt.show()

Kruskal–Wallis Tests

Non-parametric comparison of mutation frequencies across tissue types. Up to twelve tests are run (three thresholds × two segments × two metrics), so the raw p-values are corrected together with Benjamini–Hochberg (scipy.stats.false_discovery_control). Significance is judged on the corrected FDR, not on the raw p-value.

Code
kruskal_rows = []

metrics = [
    ("Mutations_per_1000bp", "Nucleotide (per 1,000 bp)"),
    ("Mutations_per_100aa",  "Amino acid (per 100 aa)"),
]

for thresh in VAF_THRESHOLDS:
    t_df = freq_df[freq_df["Threshold"] == thresh]
    for seg in SEGMENT_CONFIG:
        s_df = t_df[t_df["Segment"] == seg]
        for col, metric_label in metrics:
            groups = [s_df[s_df["Source"] == src][col].dropna()
                      for src in TISSUE_TYPES]
            groups = [g for g in groups if len(g) >= 2]
            if len(groups) < 2:
                continue
            stat, pval = kruskal(*groups)
            kruskal_rows.append({
                "Threshold":  thresh,
                "Segment":    seg,
                "Metric":     metric_label,
                "Statistic":  round(stat, 4),
                "p-value":    pval,
            })

kruskal_df = pd.DataFrame(kruskal_rows)

# ── Benjamini–Hochberg across the whole family of tests run above ────────────
if len(kruskal_df):
    kruskal_df["BH_FDR"] = false_discovery_control(
        kruskal_df["p-value"].to_numpy(), method="bh"
    )
    kruskal_df["Significant"] = kruskal_df["BH_FDR"] < FDR_ALPHA
    kruskal_df["p-value"] = kruskal_df["p-value"].round(4)
    kruskal_df["BH_FDR"] = kruskal_df["BH_FDR"].round(4)
    print(f"{len(kruskal_df)} Kruskal–Wallis tests, "
          f"{int(kruskal_df['Significant'].sum())} significant at BH FDR < {FDR_ALPHA} "
          f"({int((kruskal_df['p-value'] < 0.05).sum())} would pass an uncorrected "
          f"p < 0.05).")
else:
    print("No Kruskal–Wallis tests could be run (fewer than two groups with n ≥ 2).")

kruskal_df.to_csv(os.path.join(OUTPUT_DIR, "kruskal_results.csv"), index=False)
kruskal_df
12 Kruskal–Wallis tests, 0 significant at BH FDR < 0.05 (0 would pass an uncorrected p < 0.05).
Threshold Segment Metric Statistic p-value BH_FDR Significant
0 1% S Nucleotide (per 1,000 bp) 1.5857 0.4526 0.9222 False
1 1% S Amino acid (per 100 aa) 0.9485 0.6223 0.9222 False
2 1% M Nucleotide (per 1,000 bp) 0.2406 0.8867 0.9222 False
3 1% M Amino acid (per 100 aa) 0.3942 0.8211 0.9222 False
4 2% S Nucleotide (per 1,000 bp) 0.3483 0.8402 0.9222 False
5 2% S Amino acid (per 100 aa) 0.1621 0.9222 0.9222 False
6 2% M Nucleotide (per 1,000 bp) 1.6571 0.4367 0.9222 False
7 2% M Amino acid (per 100 aa) 2.9753 0.2259 0.9222 False
8 5% S Nucleotide (per 1,000 bp) 0.8281 0.6610 0.9222 False
9 5% S Amino acid (per 100 aa) 0.6719 0.7146 0.9222 False
10 5% M Nucleotide (per 1,000 bp) 1.4841 0.4761 0.9222 False
11 5% M Amino acid (per 100 aa) 1.7636 0.4140 0.9222 False

Summary Table

Code
summary = (
    freq_df
    .groupby(["Segment", "Threshold", "Source"])[
        ["Mutations_per_1000bp", "Mutations_per_100aa"]
    ]
    .agg(["mean", "std"])
    .round(3)
)
summary.columns = ["_".join(c) for c in summary.columns]
summary.reset_index().to_csv(os.path.join(OUTPUT_DIR, "mutation_summary.csv"), index=False)
print("Saved: mutation_summary.csv")
summary
Saved: mutation_summary.csv
Mutations_per_1000bp_mean Mutations_per_1000bp_std Mutations_per_100aa_mean Mutations_per_100aa_std
Segment Threshold Source
M 1% Lung 3.279 0.729 0.852 0.213
Saliva 3.231 0.831 0.808 0.252
Urine 3.377 0.846 0.896 0.258
2% Lung 2.105 0.506 0.543 0.117
Saliva 1.664 0.514 0.426 0.103
Urine 1.958 0.480 0.485 0.134
5% Lung 1.371 0.303 0.323 0.120
Saliva 1.224 0.432 0.338 0.152
Urine 1.077 0.480 0.264 0.136
S 1% Lung 6.327 1.595 1.667 0.430
Saliva 6.173 1.265 1.620 0.409
Urine 7.253 1.699 1.898 0.567
2% Lung 4.167 1.134 1.019 0.380
Saliva 3.704 1.549 0.972 0.421
Urine 4.012 1.621 1.065 0.478
5% Lung 2.160 0.956 0.509 0.409
Saliva 2.160 1.621 0.509 0.369
Urine 2.623 1.231 0.648 0.380