TNM[source]
The eds.tnm component extracts TNM staging mentions from clinical documents and decomposes them into structured attributes.
Extraction logic
Matching happens in two stages: a regex whose leading lookahead (logic_filter) decides whether a candidate is a TNM mention, then a post-filter that drops known lookalike abbreviations.
A span is extracted when at least one of the following conditions holds:
- T + N/M/R present: the T component is followed by at least one of N (node), M (metastasis), or R (resection), with any delimiter (space, comma, slash, newline) between them.
- Standalone qualified T: the T component carries both a prefix (e.g.
p,c,yp) and a specification (e.g.a,b,mi), even without an N/M/R component.
The pattern is case-insensitive (pT2N1M0, pt2n1m0 and PT2N1M0 are all matched). Delimiters between components can be spaces, commas, slashes, or newlines (e.g. pT2 / N1 / M0, pT2,N1,M0).
What is matched
| Input | Why |
|---|---|
pT2N1M0 | T with N and M |
T2N1 | T with N, prefix not required |
pT2 / N1 / M0 | slash, comma and newline delimiters |
p Tx N1M 0 | spaces inside and between components |
pT2b | standalone T, prefix and spec |
pT1(m)N1M0 | parenthesised T suffix (multifocal) |
pT1bN0(sn) | parenthesised N specification |
pT2N1(3/12)M0 | examined/positive node ratio |
pT2N1M1PUL | metastasis site specification |
pT4N2R1(foie) | resection status with location |
pT2N1M0 PL1 | pleural invasion (lung staging) |
pT2N1M0 (UICC 2017) | trailing classification version |
What is deliberately not matched
| Input | Why |
|---|---|
T2, pT2 | bare T without spec and without N/M/R |
T2a | spec but no prefix and no N/M/R |
PT | no T stage value |
N1M0, pN1 | the T component is mandatory |
MTX, CTX, RTX, cyto, auto, atom | in banned_words |
The banned_words post-filter also drops any match of two characters or less that does not start with a lowercase letter, which removes the many T/PT fragments produced by uppercase headings and tables.
Known limitations
a(non-invasive papillary carcinoma) is not a recognised T stage value, alongside0-4,isandx, so a full mention such aspTaN0M0is missed. The standalonepTawould be rejected by the anchor rule anyway, likepT2orpTis. Addingato the stage values is deliberately left out for now: accepting the letteroas a0already accounts for most of the observed false positives, and a bare letter as a stage value is expected to behave the same.- The anchor rule is a lookahead, so a component only has to look like an N/M/R to open it; when the component then fails to match, it is optional and the span degrades to what precedes it.
obeing a valid stage value, a following word starting withno,moorrotriggers this (pT2 nodulaireyieldspT2), and so does an out-of-range value (pT2N1 R5yieldspT2N1), since the lookahead accepts a wider value set than the components do.banned_wordscatches the bare forms, not the prefixed ones. A follow-up will enforce the rule on the parsed components instead. - A component glued to another indicator breaks the trailing word boundary:
pT3(4)N2M0R0G1yields the truncated spanpT3, andypT1cN0R0M0TRG2yields nothing. - Intercurrent noise, unusual spacing or non-standard prefixes break the component chain:
p T2 (40 mm) 9N+/20andiT3a iN0 Mx(i, for incidental or imaging, is not a recognised prefix) yield nothing, andT1c N0- M0yields the truncated spanT1c.
Decomposition
Each matched span is parsed into a TNM Pydantic model stored on span._.tnm. The following fields are extracted:
| Field | Description |
|---|---|
tumour_prefix | Prefix for T: one or two of c/p/y/r/a/u/m/s |
tumour | T stage: 0–4, is, x |
tumour_specification | T sub-spec: a/b/c/d/m/mi/x |
tumour_suffix | Parenthesised qualifier, e.g. (m)→m |
node_prefix | Modifier prefix for N |
node | N stage: 0–4, x, + |
node_specification | N sub-spec: mi/sn/i±/mol±/(3/12)/… |
node_suffix | Parenthesised qualifier for N |
metastasis_prefix | Modifier prefix for M |
metastasis | M stage: 0–3, x, + |
metastasis_specification | Site (PUL/OSS/HEP/…) or marker (i+/mol+/cy+) |
metastasis_suffix | Parenthesised qualifier for M |
pleura | PL stage 0–3 or x (lung cancer) |
resection_prefix | Modifier prefix for R |
resection | Resection completeness: 0–2, x, + |
resection_specification | R sub-spec: is/cy+ |
resection_loc | Resection location qualifier |
resection_suffix | Parenthesised qualifier for R |
version | Classification: UICC/AJCC/ACCJ/TNM |
version_year | Classification year, expanded to 4 digits |
Each component carries its own prefix: in pT1 cN1 M0 the tumour is pathological while the node is clinical, and both are kept.
Specification normalisation
Parenthesised specifications such as (sn) or (mi) are stored with their parentheses in the raw field but are stripped in norm(), so N0(sn) normalises to N0sn.
Suffixes in the normalised form
The _suffix groups are permissive on purpose, so they also pick up free text: pT2N1M0R0(marge saine) stores marge saine in resection_suffix. Only suffixes that read as a TNM qualifier (one to three letters, e.g. the (m) of pT1(m)) are carried into norm(); anything else stays available on the field but is left out of the canonical string, so span.kb_id_ remains usable for grouping.
The letter o
o and O are normalised to the digit 0, but only in the numeric stage fields (tumour, node, metastasis, pleura, resection). Free-text fields keep their letters, so N1(mol+) stays mol+ and M1OSS stays OSS.
Normalised form
span._.tnm.norm() returns a compact canonical string that concatenates all non-None components, stripping delimiters and surrounding whitespace:
{tumour_prefix}T{tumour}{tumour_specification}{tumour_suffix}
{node_prefix}N{node}{node_specification}{node_suffix}
{metastasis_prefix}M{metastasis}{metastasis_specification}{metastasis_suffix}
PL{pleura}
{resection_prefix}R{resection}{resection_specification}{resection_loc}{resection_suffix}
({VERSION} {version_year})
This value is also stored on span.kb_id_ for downstream filtering.
Examples
import edsnlp, edsnlp.pipes as eds
nlp = edsnlp.blank("eds")
nlp.add_pipe(eds.sentences())
nlp.add_pipe(eds.tnm())
text = "Conclusion : pT2c N1mi M0 R0"
doc = nlp(text)
doc.ents
# Out: (pT2c N1mi M0 R0,)
ent = doc.ents[0]
ent._.tnm.norm()
# Out: 'pT2cN1miM0R0'
ent._.tnm.dict()
# Out: {
# 'tumour_prefix': 'p',
# 'tumour': '2',
# 'tumour_specification': 'c',
# 'tumour_suffix': None,
# 'node_prefix': None,
# 'node': '1',
# 'node_specification': 'mi',
# 'node_suffix': None,
# 'metastasis_prefix': None,
# 'metastasis': '0',
# 'metastasis_specification': None,
# 'metastasis_suffix': None,
# 'pleura': None,
# 'resection_prefix': None,
# 'resection': '0',
# 'resection_specification': None,
# 'resection_loc': None,
# 'resection_suffix': None,
# 'version': None,
# 'version_year': None,
# }
Migrating from the previous version
The regex and the TNM model were rewritten. Extracted spans and norm() values are broadly compatible, and the two renamed fields keep a deprecated alias, so most code reading span._.tnm.<field> keeps working.
Renamed fields
| Before | Now | Note |
|---|---|---|
prefix | tumour_prefix | Each component has its own |
resection_completeness | resection | Was an int, now a str |
The old names still read, with a DeprecationWarning, so existing code keeps working. resection_completeness still returns an int for a numeric status, and a str for the x and + values the previous model could not represent.
tnm.prefix # -> deprecated, use tnm.tumour_prefix
tnm.resection_completeness # -> deprecated, use tnm.resection
Enums replaced by strings
Prefix, Tumour, Specification, Node, Metastasis and TnmEnum were removed from edsnlp.pipes.ner.tnm.model. Every field now holds the raw matched text as a str (except version_year, an int).
# Before -- fields were enum members
from edsnlp.pipes.ner.tnm.model import Tumour
tnm.tumour is Tumour.score_2
# Now -- fields are plain strings
tnm.tumour == "2"
The value space is not open, though. The pattern has always been the gate -- the previous one restricted stages just as narrowly ([0-4o]|is for T, [0-3o]|x for N, [01o]|x for M) and the enums merely duplicated that check in the model. Only the duplicate is gone, and the accepted values are those listed in the decomposition table above. What the enums could not represent -- N4, M2, M3, Rx, R+, metastasis site codes such as PUL -- is accepted now. Only the free-text fields are genuinely open: *_suffix, resection_loc, and node ratios such as (3/12).
New fields
node_prefix, metastasis_prefix, resection_prefix, metastasis_specification, metastasis_suffix, resection_specification, resection_loc, resection_suffix and pleura. They default to None, so existing code keeps working -- but norm() and span.kb_id_ now include them, which means a normalised value may be longer than before for the same text.
Behaviour changes to be aware of
- Matching is case-insensitive.
pt2n1m0andPT2N1M0are now extracted; previously only certain case combinations were. - A lone T is no longer extracted unless it carries both a prefix and a specification (
pT2b), or is followed by an N, M or R component.pT2andpTxused to match and no longer do. This is the main driver of the precision gain. oto0coercion is now restricted to the numeric stage fields, soM1OSSkeeps itsO. Previously every field was coerced.- A component prefix binds to its own component. In
pT1 cN1 M0thecis nownode_prefix; it used to land intumour_specification. norm()no longer concatenates free-text suffixes verbatim --pT1(grade 2)N1M0used to givepT1grade 2N1M0. Only suffixes that read as a TNM qualifier are kept; the full text stays on the field.banned_wordsis a new parameter. Pass an empty list to restore the unfiltered regex output.
Evaluation
The pipe was qualified by two physicians before production use, on an initial sample of 20 million clinical notes stratified by year, restricted to the ~5 million documents belonging to patients followed for cancer. Sampling used Neyman allocation over strata, with a minimum of 5 documents per stratum; the figures below are the corresponding stratum-weighted estimates.
| Metric | Estimate | Interval | Unit | N |
|---|---|---|---|---|
| Precision | 98.64 % | +/- 1 % (95 %) | mention | 366 |
| Recall (entity level) | 79.40 % | +/- 1 % (99 %) | mention | 120 |
| Recall (document level) | 95.53 % | +/- 1 % (99 %) | document | 120 |
Document-level recall is the share of documents containing at least one TNM mention for which at least one mention is retrieved. It is much higher than the entity-level figure because staging is usually repeated within a report.
Strata were built on the document type (pathology report / tumour board report vs. other), the oncological density of the issuing care unit, and -- for precision -- whether the mention carried only a T component, the configuration most prone to false positives. Recall strata additionally split on whether the pipe found a mention and whether the raw text contained the word TNM.
Precision errors. 21 false positives out of 366 annotations. Most come from the rule accepting the letter o as a substitute for the digit 0 (p t o m, TOM, TOC, CS tox). The rest are interfering acronyms (CMT1A, RT 3D), numbering or temporal wording (Tour 1, au T1), and mentions whose format is a valid TNM but whose context is not.
Recall errors. 27 false negatives, matching the patterns listed under Known limitations above.
Scope of these figures
The review was run on the first version of this pattern. Additional prefixes and specifications were added afterwards without re-running it, so the estimates are conservative.
Parameters
| PARAMETER | DESCRIPTION |
|---|---|
nlp | The pipeline object TYPE: |
name | The name of the pipe TYPE: |
pattern | The regex pattern used to match TNM spans. Defaults to TYPE: |
banned_words | Lowercase, whitespace- and comma-free forms that must never be returned as TNM mentions. Defaults to TYPE: |
attr | Attribute to match on, e.g. TYPE: |
label | Label name used for the TYPE: |
span_setter | How to set matches on the doc. TYPE: |
Authors and citation
The eds.tnm component was originally developed by S. Priou, B. Rance and E. Kempf (Kempf et al., 2022), and later refined by AP-HP's Data Science team.
Kempf E., Priou S., Lamé G., Daniel C., Bellamine A., Sommacale D., Belkacemi y., Bey R., Galula G., Taright N., Tannier X., Rance B., Flicoteaux R., Hemery F., Audureau E., Chatellier G. and Tournigand C., 2022. Impact of two waves of Sars-Cov2 outbreak on the number, clinical presentation, care trajectories and survival of patients newly referred for a colorectal cancer: A French multicentric cohort study from a large group of University hospitals. {International Journal of Cancer}. 150, pp.1609-1618. 10.1002/ijc.33928