Domain: Biology — Molecular Biology

Problem:
Antimicrobial resistance (AMR) emerges from the combined effects of genomic mutations, mobile genetic elements, accessory genes, and metabolic adaptations in bacterial populations under antibiotic pressure. Multi-omics datasets allow computational modeling of how these layers interact to influence strain-level resistance evolution over time.
Given the data below (AMR_multiomics_data.csv), containing \(100\) bacterial strains sampled at two timepoints: baseline and post-antibiotic exposure, including SNPs, plasmid presence & copy number, accessory genes, metabolites, observed and baseline MIC, and antibiotic concentration, implement a \(10\) -generation evolutionary simulation starting from baseline (timepoint \(0\)) with seed \(42\) of all \(100\) strains following the model specifications as given below. Compute mean \(\text{MIC}_{\log_2}\), eigenvalue of accessory genes covariance: \(\lambda_{\max}\),  fraction of strains carrying at least one of the top \(3\) plasmids by effect (plasmid_p1, plasmid_p3, plasmid_p5): \(f_{\text{plasmid}}\) and accessory gene indices average contribution: \([g_1, g_2, g_3, g_4, g_5]\) all with \(3\) decimal places precision.

Model Specifications:

1. Mutation / Toggling  

SNPs: each SNP flips (0→1 or 1→0) with probability  
\(\mu = 10^{-3} \left( 1 + \frac{\text{antibiotic\_uM}}{10} \right)\).  

Plasmids: each plasmid toggles presence with probability \(0.005\).  If gained: assign copy number uniformly from \(1 ~ to ~ 6\).  Top \(3\) plasmids by effect: plasmid_p1, plasmid_p3, plasmid_p5

Accessory genes: each toggles presence with probability \(0.002\).  

Metabolites: add Gaussian noise \(N(0, 0.05)\).  

2. MIC Computation  

Each strain's MIC after mutation is:  

\[
\text{MIC}_{\text{new}} = \text{baseline\_MIC} + \sum_{i=1}^{50} s_i \beta_i^{\text{SNP}} + \sum_{j=1}^{10} p_j \beta_j^{\text{plasmid}} + \sum_{k=1}^{30} a_k \beta_k^{\text{accessory}} + \sum_{m=1}^{20} x_m \beta_m^{\text{metabolite}}
\]  

where the true \(\beta\) vectors are unknown (used only to generate the dataset).  

3. Selection and Population Resampling  

For each strain:  

- If \(\text{MIC}_{\text{new}} > \text{baseline\_MIC} + 0.5\), it has \(0.6\) probability of duplication (higher fitness).  
- If \(\text{MIC}_{\text{new}} < \text{baseline\_MIC} - 0.5\), it has \(0.6\) probability of removal (lower fitness).  

Maintain population size = \(100\) strains via a multinomial resampling step using the adjusted fitness weights.  


After \(10\) generations, report the final answer for largest eigenvalue of accessory gene covariance as [Gene: EigenValue]

Answer: [acc_gene_19 : \(2.320\) ]

Solution code:

import numpy as np
import pandas as pd
from scipy import linalg

# ---------- CONFIG ----------
DATA_PATH = "AMR_multiomics_data.csv"   # path to your dataset file
SEED = 42
GENERATIONS = 10

# ---------- LOAD ----------
np.random.seed(SEED)
df = pd.read_csv(DATA_PATH)

# detect counts
snp_cols = [c for c in df.columns if c.startswith("snp_")]
plasmid_cols = [c for c in df.columns if c.startswith("plasmid_p") and not c.endswith("_copy")]
plasmid_copy_cols = [f"{c}_copy" for c in plasmid_cols]
accessory_cols = [c for c in df.columns if c.startswith("acc_gene_")]
metab_cols = [c for c in df.columns if c.startswith("metab_")]

n_snps = len(snp_cols)
n_plasmids = len(plasmid_cols)
n_accessory = len(accessory_cols)
n_metab = len(metab_cols)

# ---------- ESTIMATE betas (OLS) ----------
# Target is observed_MIC - baseline_MIC
y = (df["observed_MIC_log2"].values - df["baseline_MIC_log2"].values).astype(float)
X = np.hstack([df[snp_cols].values.astype(float),
               df[plasmid_cols].values.astype(float),
               df[accessory_cols].values.astype(float),
               df[metab_cols].values.astype(float)])
# Solve least squares for beta
beta_hat, resid, rank, s = np.linalg.lstsq(X, y, rcond=None)

# split betas
idx = 0
beta_snp = beta_hat[idx: idx + n_snps]; idx += n_snps
beta_plasmid = beta_hat[idx: idx + n_plasmids]; idx += n_plasmids
beta_accessory = beta_hat[idx: idx + n_accessory]; idx += n_accessory
beta_metab = beta_hat[idx: idx + n_metab]; idx += n_metab

# ---------- Extract baseline strains (timepoint 0) ----------
baseline_df = df[df["timepoint"] == 0].reset_index(drop=True)
n_strains = baseline_df.shape[0]
assert n_strains == 100, "Expected 100 baseline strains."

# Baseline matrices
snp_base = baseline_df[snp_cols].values.astype(int)              # (100, n_snps)
plasmid_base = baseline_df[plasmid_cols].values.astype(int)    # (100, n_plasmids)
plasmid_copy_base = baseline_df[plasmid_copy_cols].values.astype(int)
acc_base = baseline_df[accessory_cols].values.astype(int)      # (100, n_accessory)
metab_base = baseline_df[metab_cols].values.astype(float)      # (100, n_metab)
baseline_MIC = baseline_df["baseline_MIC_log2"].values.astype(float)
antibiotic_uM_base = baseline_df["antibiotic_uM"].values.astype(float)

# ---------- Initialize population (one copy of each baseline strain) ----------
pop_size = n_strains
pop_snp = snp_base.copy()
pop_plasmid = plasmid_base.copy()
pop_plasmid_copy = plasmid_copy_base.copy()
pop_acc = acc_base.copy()
pop_metab = metab_base.copy()
pop_baseline_MIC = baseline_MIC.copy()
pop_antibiotic = antibiotic_uM_base.copy()

# Simulation parameters
mu_base = 1e-3
p_plasmid = 0.005
p_accessory = 0.002
metab_sigma = 0.05

rng = np.random.default_rng(SEED)

def compute_MIC_new(baseline_MICs, snp_arr, plasmid_arr, acc_arr, metab_arr):
    return (baseline_MICs
            + snp_arr.dot(beta_snp)
            + plasmid_arr.dot(beta_plasmid)
            + acc_arr.dot(beta_accessory)
            + metab_arr.dot(beta_metab))

# ---------- Evolution loop ----------
for gen in range(1, GENERATIONS + 1):
    # SNP flips
    mu_per_ind = mu_base * (1.0 + pop_antibiotic / 10.0)  # shape (pop_size,)
    flips = rng.random(pop_snp.shape) < mu_per_ind[:, None]
    pop_snp = np.abs(pop_snp - flips.astype(int))

    # Plasmid toggles
    plasmid_tog = rng.random(pop_plasmid.shape) < p_plasmid
    gained = (plasmid_tog) & (pop_plasmid == 0)
    lost = (plasmid_tog) & (pop_plasmid == 1)
    pop_plasmid = np.abs(pop_plasmid - plasmid_tog.astype(int))
    # assign copy numbers where gained
    if gained.any():
        gidx = np.where(gained)
        pop_plasmid_copy[gidx] = rng.integers(1, 7, size=gidx[0].shape[0])
    if lost.any():
        lidx = np.where(lost)
        pop_plasmid_copy[lidx] = 0

    # Accessory toggles
    acc_tog = rng.random(pop_acc.shape) < p_accessory
    pop_acc = np.abs(pop_acc - acc_tog.astype(int))

    # Metabolite perturbation
    pop_metab = pop_metab + rng.normal(0, metab_sigma, size=pop_metab.shape)

    # Compute MIC_new
    MIC_new = compute_MIC_new(pop_baseline_MIC, pop_snp, pop_plasmid, pop_acc, pop_metab)

    # Selection weights: duplication -> *1.6 ; removal -> *0.4
    weights = np.ones(pop_size, dtype=float)
    weights[MIC_new > (pop_baseline_MIC + 0.5)] *= 1.6
    weights[MIC_new < (pop_baseline_MIC - 0.5)] *= 0.4
    probs = weights / weights.sum()

    # Resample via multinomial
    counts = rng.multinomial(pop_size, probs)
    new_idx = np.repeat(np.arange(pop_size), counts)
    # safety: if length mismatch (shouldn't happen)
    if new_idx.size != pop_size:
        if new_idx.size > pop_size:
            new_idx = new_idx[:pop_size]
        else:
            extra = rng.integers(0, pop_size, size=(pop_size - new_idx.size))
            new_idx = np.concatenate([new_idx, extra])

    # replicate arrays
    pop_snp = pop_snp[new_idx].copy()
    pop_plasmid = pop_plasmid[new_idx].copy()
    pop_plasmid_copy = pop_plasmid_copy[new_idx].copy()
    pop_acc = pop_acc[new_idx].copy()
    pop_metab = pop_metab[new_idx].copy()
    pop_baseline_MIC = pop_baseline_MIC[new_idx].copy()
    pop_antibiotic = pop_antibiotic[new_idx].copy()

    print(f"Generation {gen} done.")

# ---------- Results ----------
final_MIC = compute_MIC_new(pop_baseline_MIC, pop_snp, pop_plasmid, pop_acc, pop_metab)
mean_MIC = final_MIC.mean()

# B) top 5 accessory genes by average contribution
mean_acc_presence = pop_acc.mean(axis=0)
acc_contrib = beta_accessory * mean_acc_presence
top5_idx = np.argsort(-acc_contrib)[:5] + 1  # 1-based indices

# C) principal eigenvalue of accessory covariance, plus the gene with highest loading
cov_acc = np.cov(pop_acc, rowvar=False, bias=False)
eigvals, eigvecs = np.linalg.eig(cov_acc)
max_idx = np.argmax(eigvals)
lambda_max = np.real(eigvals[max_idx])
principal_vec = np.real(eigvecs[:, max_idx])
top_gene_load_idx = np.argmax(np.abs(principal_vec)) + 1  # 1-based

# D) fraction with at least 1 of plasmid_p1, p3, p5
check_plasmids = ["plasmid_p1", "plasmid_p3", "plasmid_p5"]
plasmid_name_to_idx = {name: i for i, name in enumerate(plasmid_cols)}
check_idxs = [plasmid_name_to_idx[c] for c in check_plasmids]
has_top_plasmid = (pop_plasmid[:, check_idxs].sum(axis=1) >= 1).mean()

# Format with 3 decimals
mean_MIC_out = float(f"{mean_MIC:.3f}")
lambda_max_out = float(f"{lambda_max:.3f}")
fraction_plasmid_out = float(f"{has_top_plasmid:.3f}")
top5_out = [int(i) for i in top5_idx]



# Print results
print("\n--- RESULTS (3 decimals) ---")
print(f"A) Mean MIC_log2: {mean_MIC_out:.3f}")
print(f"B) Top 5 accessory gene indices (1-based): {top5_out}")
print(f"C) Principal eigenvalue lambda_max: {lambda_max_out:.3f}")
print(f"   Principal gene (max loading): acc_gene_{top_gene_load_idx} : {lambda_max_out:.3f}")
print(f"D) Fraction with >=1 of plasmid_p1,p3,p5: {fraction_plasmid_out:.3f}")