Example: GTEx

GTEx’s gene median TPM table names every gene twice, in two columns:

column

holds

example

Name

Ensembl gene ID, versioned

ENSG00000223972.6

Description

gene symbol

DDX11L1

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