Mutation data with cBioPortal
Before trying to predict a mutation from H&E: prevalence, co-occurrence, survival and the slide-level label file
🎯 Learning objectives
- Distinguish a study, a sample list and a molecular profile
- Read the OncoPrint, mutual exclusivity and survival tabs of the web interface
- Pull a gene × sample binary mutation matrix from the API and compute frequencies
- Test co-occurrence / mutual exclusivity with Fisher’s exact test; plot survival with Kaplan–Meier
- Go from a TCGA sample barcode to the diagnostic slide file; build the label file at patient level
- Say what the “mutant” label covers (driver / VUS, exome / panel, hypermutators)
Worked example: TCGA lung adenocarcinoma
The module uses the Lung Adenocarcinoma (TCGA, PanCancer Atlas) study throughout (The Cancer Genome Atlas Research Network 2014; Hoadley et al. 2018): study id luad_tcga_pan_can_atlas_2018, 566 samples with mutation data. Mutation calls come from the MC3 pipeline (Ellrott et al. 2018). Four genes: EGFR (targeted therapy; the classic target of the H&E-prediction literature (Coudray et al. 2018)), KRAS (the most frequent driver), TP53 (the most frequently altered gene), STK11 (associated with immunotherapy response).
A · Web interface (no code)
1
cbioportal.org → Query tab. Type lung adenocarcinoma tcga pancancer in the search box and tick Lung Adenocarcinoma (TCGA, PanCancer Atlas). On the right, the “Mutations” row reads 566.
2
Query By Gene → Select Molecular Profiles: leave only Mutations ticked (untick CNA and mRNA; our label is mutation). In Enter Genes type EGFR KRAS TP53 STK11 → Submit Query.
3
OncoPrint tab. Each column is a sample, each row a gene. The percentage at the row head is the fraction of altered samples. Scan the columns: EGFR and KRAS almost never appear in the same column. The Download button at the top right exports the table.
4
Mutual Exclusivity tab. For every gene pair: log2 odds ratio, p-value and a “Mutual exclusivity / Co-occurrence” label. EGFR–KRAS is mutually exclusive (one of two drivers in the same pathway suffices); TP53 is independent of or co-occurs with most genes.
5
Comparison/Survival tab → Survival. Groups arrive as “Altered” and “Unaltered”; read the Overall Survival curve and the log-rank p-value. Note that the groups are defined by alteration in any of the four genes. For a single gene, go back to Query with that gene only, or use the code in part D.
6
Download tab → Mutation data (sample × gene matrix) or Alteration data. This is the no-code version of the MIL label matrix: one row per sample, one column per gene, the protein change in the cell when mutated.
B · API: the gene × sample matrix
cBioPortal’s public REST API (https://www.cbioportal.org/api, OpenAPI document at /api/swagger-ui/) needs no authentication. The code below builds the binary mutation matrix in three calls. It is not executed here; copy it into your own environment.
cbio_matrix.py
import requests, pandas as pd
API = "https://www.cbioportal.org/api"
STUDY = "luad_tcga_pan_can_atlas_2018"
PROFILE = f"{STUDY}_mutations" # molecular profile: mutations
SAMPLES = f"{STUDY}_sequenced" # sample list: samples with mutation data
GENES = ["EGFR", "KRAS", "TP53", "STK11"]
# 1) HUGO symbol → Entrez id (the API filters mutations by Entrez id)
genes = requests.post(f"{API}/genes/fetch",
params={"geneIdType": "HUGO_GENE_SYMBOL"},
json=GENES).json()
entrez = {g["hugoGeneSymbol"]: g["entrezGeneId"] for g in genes}
# 2) Sample list → ids of all sequenced samples (566)
sl = requests.get(f"{API}/sample-lists/{SAMPLES}").json()
all_samples = sl["sampleIds"]
# 3) Mutation records of the chosen genes (a sample may have several)
muts = requests.post(f"{API}/molecular-profiles/{PROFILE}/mutations/fetch",
params={"projection": "DETAILED"},
json={"sampleListId": SAMPLES,
"entrezGeneIds": list(entrez.values())}).json()
mdf = pd.DataFrame(muts)[["sampleId", "patientId", "entrezGeneId",
"proteinChange", "mutationType"]]
mdf["gene"] = mdf["entrezGeneId"].map({v: k for k, v in entrez.items()})
# Binary matrix: row = sample, column = gene, 1 = at least one mutation
M = (mdf.assign(v=1).pivot_table(index="sampleId", columns="gene",
values="v", aggfunc="max")
.reindex(all_samples).fillna(0).astype(int))
print(M.sum().sort_values(ascending=False) / len(M)) # frequencies
M.to_csv("luad_mutation_matrix.csv")Expected output (November 2026 data): EGFR 0.124 (70/566); KRAS, TP53 and STK11 about 0.30, 0.50 and 0.15.
C · Co-occurrence and mutual exclusivity
A 2×2 table and Fisher’s exact test per gene pair; a reproduction of the web interface’s Mutual Exclusivity tab.
cbio_pairs.py
import itertools, numpy as np, pandas as pd
from scipy.stats import fisher_exact
M = pd.read_csv("luad_mutation_matrix.csv", index_col=0)
rows = []
for a, b in itertools.combinations(M.columns, 2):
t = pd.crosstab(M[a], M[b]).reindex(index=[0, 1], columns=[0, 1], fill_value=0)
odds, p = fisher_exact(t.values)
rows.append({"A": a, "B": b, "both": t.loc[1, 1], "A_only": t.loc[1, 0],
"B_only": t.loc[0, 1], "neither": t.loc[0, 0],
"log2_OR": np.log2(odds) if odds > 0 else -np.inf, "p": p})
print(pd.DataFrame(rows).sort_values("p"))Reading: for EGFR–KRAS, both is near zero and the log2 odds ratio is clearly negative: mutual exclusivity. This carries a question back to Slide Bag Net: is the pattern an EGFR predictor learns “EGFR present” or “KRAS absent”? In an external cohort with a different KRAS frequency the two behave differently.
D · Survival
Clinical data are at patient level; mutations are at sample level. First map sample → patient, then Kaplan–Meier.
cbio_survival.py
import requests, pandas as pd
from lifelines import KaplanMeierFitter
from lifelines.statistics import logrank_test
API = "https://www.cbioportal.org/api"; STUDY = "luad_tcga_pan_can_atlas_2018"
M = pd.read_csv("luad_mutation_matrix.csv", index_col=0)
samples = pd.DataFrame(requests.get(f"{API}/studies/{STUDY}/samples",
params={"projection": "SUMMARY"}).json())
s2p = samples.set_index("sampleId")["patientId"]
M["patientId"] = s2p.reindex(M.index).values
# Patient-level label: 1 if any sample of the patient is mutated
P = M.groupby("patientId").max()
clin = pd.DataFrame(requests.get(f"{API}/studies/{STUDY}/clinical-data",
params={"clinicalDataType": "PATIENT",
"projection": "SUMMARY"}).json())
clin = clin.pivot(index="patientId", columns="clinicalAttributeId", values="value")
df = P.join(clin[["OS_STATUS", "OS_MONTHS"]], how="inner").dropna()
df["event"] = df["OS_STATUS"].str.startswith("1").astype(int) # "1:DECEASED"
df["OS_MONTHS"] = df["OS_MONTHS"].astype(float)
gene = "EGFR"
km = KaplanMeierFitter()
for v, lab in [(1, f"{gene} mutant"), (0, f"{gene} wild-type")]:
g = df[df[gene] == v]
km.fit(g["OS_MONTHS"], g["event"], label=f"{lab} (n={len(g)})").plot_survival_function()
r = logrank_test(df[df[gene]==1]["OS_MONTHS"], df[df[gene]==0]["OS_MONTHS"],
df[df[gene]==1]["event"], df[df[gene]==0]["event"])
print("log-rank p =", round(r.p_value, 4))The same table can be exported with df.to_csv("luad_os.csv") and analysed in jamovi with the ClinicoPath module; that is the no-code route.
E · From sample to slide: the label file for MIL
A TCGA sample barcode such as TCGA-05-4244-01 is project – tissue source site (05) – participant (4244) – sample type (01 = primary tumour). The diagnostic H&E slide of the same case is on the GDC Data Portal as TCGA-05-4244-01Z-00-DX1.<uuid>.svs (DX = diagnostic FFPE, TS = frozen section; use DX for MIL). The tissue source site code (05, 44, 55, …) is also a proxy for the scanning and staining site: Howard et al. (2021) used it to show how site-specific signatures inflate mutation predictors. The “lab B” trap in Slide Bag Net is the simulation version of this code.
cbio_labels.py
import pandas as pd
M = pd.read_csv("luad_mutation_matrix.csv", index_col=0)
lab = pd.DataFrame({
"sample_id": M.index,
"patient_id": M.index.str.slice(0, 12), # TCGA-05-4244
"site_code": M.index.str.slice(5, 7), # 05 → for site stratification
"label_EGFR": M["EGFR"].values,
})
# Split at patient and site level; NEVER at slide level.
print(lab["label_EGFR"].mean(), lab["site_code"].nunique())
lab.to_csv("luad_egfr_labels.csv", index=False)Check yourself
The OncoPrint shows EGFR at 12%. If you build the label file for an H&E EGFR predictor straight from that column, what happens?
Fisher's test shows strong mutual exclusivity for EGFR–KRAS. Why does this matter for an EGFR predictor?
At which level do you split the label file into training and test?
References
Works cited on this page are listed below; for the whole series see References. cBioPortal documentation: docs.cbioportal.org; API: cbioportal.org/api/swagger-ui; R client: cbioportalR; Bioconductor: cBioPortalData.