Exploring the tumor¶
This notebook digs into the biology stage: spatial structure, the CINner-style selection
model, and clonal dynamics. We rebuild the same small tumor the
other notebooks use (example_config.yaml, seed 2), keeping the live Tumor object so we can
colour the spatial grid by any per-cell quantity.
%matplotlib inline
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import iscc
from iscc.tumor.models import GenotypeTumor
from iscc.constants import normal_names
CONFIG = os.path.join(os.getcwd(), "example_config.yaml")
# The same cm-scale DCIS -> IDC tissue as the other notebooks (~8.5 mm, ~10^6 cells). Growing to the
# DCIS->IDC state takes ~5 min (tau-leaping + passenger coarsening).
tumor = GenotypeTumor(config=CONFIG, seed=2)
# The lesion spends a long opening phase shut inside its founding acinus, growing but going nowhere —
# that is where its clonal backbone is made. Only then does it spread and invade.
for _ in range(100): # grow to the confluent DCIS->IDC of the landing hero (~190k
tumor.grow(n_steps=20, seed=2) # cancer). Invasion is slow and breach-gated, so grow until it
if tumor.get_cancer_size() >= 150_000: # coalesces rather than for a fixed number of steps — most of
break # the budget goes on the confined phase before anything moves.
print("cancer:", tumor.get_cancer_size(), "| total:", tumor.get_tumor_size(),
"| genome:", tumor.n_genes, "genes")
cancer: 225623 | total: 491219 | genome: 6000 genes
Spatial structure¶
plot_tissue draws the deme grid straight from the per-deme counts, so the spatial overview stays
complete at cm-scale (plot_grid, which reads the memory-safe cell_data subsample, would go holey).
Four views of the same tissue:
"state"— categorical histology: stroma / duct(normal) / DCIS (ductal carcinoma in situ — cancer inside a duct) / IDC (invasive ductal carcinoma — cancer invaded into the stroma);"cancer_frac"— the per-deme cancer fraction (density);"clone"— each cancer deme coloured by its dominant driver clone (the distinct set of driver oncogene/TSG mutations), matching the Muller and phylogeny below;"stage"— each cancer deme coloured by the stage-dominant trait its clone has reached (none → proliferation → invasion). This is the same scheme the landing animation uses for the full metastatic cascade; with no metastasis or treatment here, it reads out the DCIS → IDC progression.
You can see the single founder's DCIS ducts and the focal invasive masses that broke out of them.
fig, axes = plt.subplots(1, 4, figsize=(20, 5))
tumor.plot_tissue(ax=axes[0], color="state")
axes[0].set_title("State: stroma / duct / DCIS / IDC")
tumor.plot_tissue(ax=axes[1], color="cancer_frac")
axes[1].set_title("Cancer fraction per deme")
tumor.plot_tissue(ax=axes[2], color="clone")
axes[2].set_title("Dominant driver clone")
tumor.plot_tissue(ax=axes[3], color="stage")
axes[3].set_title("Stage-dominant trait")
plt.tight_layout()
Clonal dynamics¶
The Muller plot shows how cancer clones rise and fall over the simulation. by_drivers=True colours
by driver clone — the distinct combination of driver oncogene/TSG mutations a clone carries —
collapsing passenger-only diversity so the sweeps are legible; min_freq merges tiny clones. This is
the same colouring as the clone grid above and the phylogeny below — one founder, three views.
fig, ax = plt.subplots(figsize=(9, 4))
tumor.plot_muller(ax=ax, by_drivers=True, min_freq=0.02)
ax.set_title("Clonal dynamics (by driver clone) — a single founder + its subclones")
Text(0.5, 1.0, 'Clonal dynamics (by driver clone) — a single founder + its subclones')
Cell phylogeny¶
The Muller shows clone frequencies over time; the phylogeny shows the ancestry directly. Instead of subsampling cells, we collapse the population to driver clones (the same collapse the Muller uses) and draw the clade genealogy radially — one node per clade, sized by cell count, in the same driver-clone colours as the Muller and grid, so the centre is the founder and the outer clades are its driver subclones.
# Radial CLADE phylogeny, drawn straight to the axes (no image file). The population is collapsed to
# driver clones (no cell subsampling) — one node per clade, sized by cell count, in the SAME
# driver-clone colours as the Muller and the grid.
fig, ax = plt.subplots(figsize=(6, 6))
tumor.plot_phylogeny(ax=ax)
ax.set_title("Clade phylogeny (by driver clone)")
Text(0.5, 1.0, 'Clade phylogeny (by driver clone)')
The CINner-style selection model¶
Fitness is copy-number aware: oncogene copies/mutations raise the division rate, tumour-suppressor copies/mutations lower it. We can see this in the per-cell ground truth — cells carrying more mutated oncogenes tend to divide faster.
The selection model¶
A cell's division rate is read off its genome in four steps. Write $\pi$ for the cell's ploidy (its mean copy number), $d$ for the per-copy driver effect, and $N$ for the number of genes of a given class.
1 — Driver dosage. Each driver-gene copy shifts fitness, measured against a wild-type diploid genome:
$$\mathrm{rel}(n_{\mathrm{wt}}, n_{\mathrm{mut}}) \;=\; \exp\!\left[\frac{2 n_{\mathrm{wt}}}{\pi}\ln w \;+\; \frac{2 n_{\mathrm{mut}}}{\pi}\ln m \;-\; 2N\ln w\right]$$
Oncogenes take $(w,m)=(d,\,d^{2})$ — gaining a copy helps, and mutating one is worth twice as much again. Tumour suppressors take $(w,m)=(1/d,\,1)$ — losing a wild-type copy helps, and an already-mutated copy is neutral. The two multiply:
$$F_{\mathrm{div}} \;=\; \mathrm{rel}_{\mathrm{onc}} \cdot \mathrm{rel}_{\mathrm{tsg}} \cdot E$$
with $E$ an optional epistasis term (1 when off). Everything is divided by $\pi$, so only a segment's driver density relative to the genome average matters — which is why a whole-genome doubling is exactly neutral.
2 — Traits are earned by mutation. Invasion and niche traits read only the mutated copies:
$$F_{\mathrm{trait}} \;=\; \exp\!\left[\frac{2 n_{\mathrm{mut}}}{\pi}\ln e\right], \qquad \mathrm{trait} \;=\; \max\!\left(0,\; 1 - \frac{1}{F_{\mathrm{trait}}}\right) \in [0,1)$$
3 — Go-or-grow. Every trait costs proliferation everywhere, while its benefit applies only in its own compartment:
$$C \;=\; \prod_i \max\!\left(0,\; 1 - c_i\,\mathrm{trait}_i\right)$$
over breach, stromal survival, metastatic survival and treatment resistance.
4 — The realised rate. The cap binds before the cost, so a clone already at the ceiling still pays in full:
$$b \;=\; \min\!\left(b_0 F_{\mathrm{div}},\; b_{\max}\right)\cdot C$$
Separately, a clone is non-viable (never born) if its ploidy, highest copy number, nullisomy count, or total driver load exceeds its limit.
One consequence is worth seeing before the next plot. With breach_cost and stromal_survival_cost both
0.6, a cell carrying both invasion traits keeps only $0.4 \times 0.4 = 0.16$ of its division rate. That is a
far bigger swing than a driver or two buys — so among cells that have started to invade, division rate
tracks trait load, not driver count.
evo = tumor.cell_data["cell_evo"].copy()
evo = evo[evo["division_rate"] > 0] # cancer cells (normal cells have division_rate 0)
fig, ax = plt.subplots(figsize=(6, 4))
# Split on trait state: the go-or-grow cost dominates, so pooling the two hides the driver signal.
has_trait = (evo["breach"] > 1e-9) | (evo["stromal_survival"] > 1e-9)
for m, lab, col in ((~has_trait, "no invasion trait", "#2c7fb8"), (has_trait, "invading", "#c51b8a")):
if m.sum():
ax.scatter(evo["n_mut_onc"][m], evo["division_rate"][m], alpha=0.4, s=12, c=col, label=lab)
ax.legend(fontsize=8, markerscale=1.6)
ax.set_xlabel("mutated oncogene copies (n_mut_onc)")
ax.set_ylabel("division rate")
ax.set_title("Driver load vs division rate, split by invasion trait")
Text(0.5, 1.0, 'Driver load vs division rate, split by invasion trait')
Next: iscc overview takes a sample of a tumor like this one and walks through every molecular assay (bulk/single-cell DNA, scRNA, Visium).