Example: GTEx
GTEx’s gene median TPM table names every gene twice, in two columns:
column |
holds |
example |
|---|---|---|
|
Ensembl gene ID, versioned |
|
|
gene symbol |
|
Any identifier or gene name could go out of date.
Since downstream analyses or integration with your data could depend on one of these columns, pysec2pri can be used to update these IDs and labels or spot issues.
The last part of the notebook shows how to use the sec2pri mapping sets with additional context (HGNC’s HGNC symbol->Ensembl ID mappings) to solve the remaining ambiguity issues.
[1]:
import pandas as pd
URL = (
"https://storage.googleapis.com/adult-gtex/bulk-gex/v11/rna-seq/"
"GTEx_Analysis_2025-08-22_v11_RNASeQCv2.4.3_gene_median_tpm.gct.gz"
)
df = pd.read_csv(URL, sep="\t", skiprows=2, usecols=["Name", "Description"], dtype=str)
# GTEx versions its IDs; nothing else here does. Drop it.
df["ensembl"] = df["Name"].str.split(".").str[0]
# Ensembl's own mapping set writes IDs as CURIEs; HGNC's crosswalk does not.
df["ensembl_curie"] = "ENSEMBL:" + df["ensembl"]
df.head(3)
[1]:
| Name | Description | ensembl | ensembl_curie | |
|---|---|---|---|---|
| 0 | ENSG00000290825.2 | DDX11L16 | ENSG00000290825 | ENSEMBL:ENSG00000290825 |
| 1 | ENSG00000223972.6 | DDX11L1 | ENSG00000223972 | ENSEMBL:ENSG00000223972 |
| 2 | ENSG00000310526.1 | WASH7P | ENSG00000310526 | ENSEMBL:ENSG00000310526 |
Symbols
update_labels adds Description_current: the symbol HGNC uses today.
[2]:
from pysec2pri import generate_labels, update_labels
hgnc = generate_labels("hgnc", show_progress=False)
plain = update_labels(df, hgnc, at="Description")
current = plain["Description_current"]
renamed = plain[(current != "") & (current != plain["Description"])]
renamed[["Description", "Description_current"]].head()
[2]:
| Description | Description_current | |
|---|---|---|
| 31 | U6 | RNU6-50P |
| 122 | TMEM88B | TMEM278 |
| 460 | C1orf127 | CIROZ |
| 631 | RSC1A1 | DDI2 |
| 962 | NCMAP-DT | RCAN3AS |
Some rows come back empty. Those symbols are ambiguous: the symbol is both a retired name of one gene and the current name of another, so there is no safe answer and pysec2pri does not guess.
[3]:
ambiguous = plain[plain["Description_current"] == ""]
print(f"renamed: {len(renamed)}")
print(f"ambiguous: {len(ambiguous)}")
ambiguous[["Name", "Description"]].head()
renamed: 362
ambiguous: 564
[3]:
| Name | Description | |
|---|---|---|
| 1 | ENSG00000223972.6 | DDX11L1 |
| 290 | ENSG00000116254.18 | CHD5 |
| 326 | ENSG00000207056.1 | RNU1-8P |
| 359 | ENSG00000200975.1 | RNU1-7P |
| 464 | ENSG00000116649.10 | SRM |
Symbols, settled by the Ensembl column
The issues with HGNC symbols can be repaired by adding a custom cross-reference.
Here xref_source("hgnc_custom") (a link to the custom download mapping HGNC symbols to Ensembl IDs at https://www.genenames.org/download/custom/) is used, but any table works as a hint, as long as its object_id matches the IDs of the mapping set you are resolving against.
See more at the Update IDs and labels page.
[4]:
from mapkgsutils.context import download_xref_source
from mapkgsutils.parsers.config import get_datasource_config
src = get_datasource_config("hgnc", config_package="pysec2pri.config").xref_source("hgnc_custom")
print(src.note)
ens_to_hgnc = download_xref_source(src, src.subject_id_cols["ensembl"], show_progress=False)
len(ens_to_hgnc.records)
Source of truth for cross-references varies; this is a suggested default
[4]:
40567
[5]:
hinted = update_labels(
df,
hgnc,
at="Description",
xref="ensembl",
xref_mapping=ens_to_hgnc,
report_path="symbol_decisions.tsv",
)
settled = ambiguous.index.difference(hinted[hinted["Description_current"] == ""].index)
print(f"ambiguous before: {len(ambiguous)}")
print(f"settled by the hint: {len(settled)}")
hinted.loc[settled, ["Description", "Description_current", "Description_current_id"]].head()
ambiguous before: 564
settled by the hint: 561
[5]:
| Description | Description_current | Description_current_id | |
|---|---|---|---|
| 1 | DDX11L1 | DDX11L1 | HGNC:37102 |
| 290 | CHD5 | CHD5 | HGNC:16816 |
| 326 | RNU1-8P | RNU1-8P | HGNC:48307 |
| 359 | RNU1-7P | RNU1-7P | HGNC:48306 |
| 464 | SRM | SRM | HGNC:11296 |
The hint is only consulted for ambiguous rows, and every decision is written down.
[6]:
report = pd.read_csv("symbol_decisions.tsv", sep="\t")
report.head(3)
[6]:
| stage | token | predicate_id | candidate | accepted | reason | |
|---|---|---|---|---|---|---|
| 0 | xref_filter | ENSG00000223972 | NaN | DDX11L1 | True | no predicate given, assumed equivalence |
| 1 | xref_filter | ENSG00000116254 | NaN | CHD5 | True | no predicate given, assumed equivalence |
| 2 | xref_filter | ENSG00000207056 | NaN | RNU1-8P | True | no predicate given, assumed equivalence |
The other direction
Now resolve the Ensembl IDs against Ensembl, and hint with the symbol column.
[7]:
from pysec2pri import generate_ids, update_ids
ensembl = generate_ids("ensembl", version="115", species="9606", show_progress=False)
plain_ids = update_ids(df, ensembl, at="ensembl_curie")
changed = plain_ids[plain_ids["ensembl_curie_primary"] != plain_ids["ensembl_curie"]]
stale = plain_ids[plain_ids["ensembl_curie_primary"] == ""]
print(f"retired or withdrawn: {len(changed)}")
print(f"ambiguous: {len(stale)}")
changed[["ensembl_curie", "ensembl_curie_primary", "Description"]].head()
retired or withdrawn: 133
ambiguous: 83
[7]:
| ensembl_curie | ensembl_curie_primary | Description | |
|---|---|---|---|
| 93 | ENSEMBL:ENSG00000304412 | sssom:NoTermFound | ENSG00000304412 |
| 305 | ENSEMBL:ENSG00000171680 | PLEKHG5 | |
| 763 | ENSEMBL:ENSG00000309633 | sssom:NoTermFound | ENSG00000309633 |
| 961 | ENSEMBL:ENSG00000288982 | sssom:NoTermFound | ENSG00000288982 |
| 2246 | ENSEMBL:ENSG00000185483 | ROR1 |
sssom:NoTermFound means Ensembl withdrew the gene without a replacement.
The same HGNC table read the other way round gives symbol -> Ensembl ID.
[8]:
from mapkgsutils.context import XrefMapping, XrefRecord
raw = pd.read_csv(src.url, sep="\t", dtype=str)
pairs = raw[[src.object_label_col, src.subject_id_cols["ensembl"]]].dropna()
sym_to_ens = XrefMapping(
records=[
XrefRecord(subject_id=s, object_id=f"ENSEMBL:{e}") for s, e in pairs.itertuples(index=False)
]
)
hinted_ids = update_ids(
df, ensembl, at="ensembl_curie", xref="Description", xref_mapping=sym_to_ens
)
print(f"ambiguous before: {len(stale)}")
print(f"ambiguous after: {len(hinted_ids[hinted_ids['ensembl_curie_primary'] == ''])}")
ambiguous before: 83
ambiguous after: 1