SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
8.6 KB

# Research Gap Index — methodology

Formula versions: components ci-research-gap-components-v1; rankings ci-trial-gap-ratio-v1, ci-research-gap-ratio-v1, ci-trials-per-deaths-v1, ci-pubs-per-deaths-v1. Code: packages/ranking/src/research-gap.ts (unit tests in packages/ranking/test/research-gap.test.ts). Table: research_gap_components. Web: /research-gap, /rankings/trial_gap_ratio, /rankings/research_gap_ratio. API: GET /v1/research-gap, GET /v1/research-gap/scopes.

# What it measures

The Research Gap Index compares, inside one burden scope, each top-level cancer's share of deaths with its share of registered research activity:

  • active interventional trials registered on ClinicalTrials.gov and mapped to the cancer or one of its NCIt descendants (entity_counters.active_trial_count);
  • publications of the last five years in PubMed (entity_counters.publication_count_5y, falling back to literature_counts window 5y when the counter is empty; the exact query is stored on the literature row and copied into inputs).

It is a computed metric (claim category computed_metric). It says where registered activity is thin relative to mortality. It does not say anything about the quality, cost, difficulty or funding of research, and it inherits every limitation of the burden source (site definitions, coding, completeness).

# Scope

A scope is a (geography, year, sex, burden source) combination in which at least 10 top-level cancers carry a mortality_count observation with age_group = 'all' — exactly the discovery rule of the ranking engine (packages/ranking/src/engine.ts). Every sex present is a separate scope (all, male, female). Scopes are rebuilt from scratch on each run (pnpm cix intel): the rows of a scope are deleted and reinserted in one transaction, then four ranking snapshots are persisted through the shared persistSnapshot so "Why this rank?", previous-rank deltas, TRACE and CSV exports work like for any other metric.

Burden today is United States only (CDC WONDER underlying-cause mortality; U.S. Cancer Statistics NVSS mortality). IARC / GLOBOCAN is under license review, so no WORLD scope exists; a US death share must not be read as a global death share.

# Eligibility

Rule Effect
deaths ≥ 100 in the scope (eligibility.minDeaths of the four metric definitions) the cancer enters the eligible set E; below the threshold the row is stored with eligible = false and ineligible_reason = "deaths_below_threshold (n < 100)", shares and ratios NULL
no mortality observation not stored (the scope is built from mortality observations) — the reason no_mortality_observation exists in the pure function for completeness
active_trials ≥ 1 needed for trial_gap_ratio (log₂ undefined at 0); otherwise that ratio is NULL, the row stays eligible and its 0 trials still count in Σ
publications_5y ≥ 1 same for research_gap_ratio

Per-1,000-deaths intensities are computed for every stored row (they need no cross-entity denominator) but only eligible rows are ranked. Ranking confidence is HIGH when deaths ≥ 1000, MEDIUM otherwise.

Sex-specific scopes naturally produce zero-death rows (e.g. prostate in the female scope): they are stored as ineligible, never dropped.

# Shares

All sums run over the eligible set E of the scope:

text
death_share_i        = deaths_i        / Σ_{j∈E} deaths_j
trial_share_i        = active_trials_i / Σ_{j∈E} active_trials_j
publication_share_i  = publications_5y_i / Σ_{j∈E} publications_5y_j

Shares are stored rounded to 6 decimals; the ratios below are computed from the unrounded shares (so rounding never leaks into the index). Each share column sums to 1 over E.

# Ratios and intensities

text
trial_gap_ratio_i     = log2( death_share_i / trial_share_i )
research_gap_ratio_i  = log2( death_share_i / publication_share_i )
trials_per_1000_deaths_i        = active_trials_i   / (deaths_i / 1000)
publications_per_1000_deaths_i  = publications_5y_i / (deaths_i / 1000)

Ratios are rounded to 6 decimals, intensities to 4. Rankings sort descending: rank 1 = largest positive gap.

# Why log₂

The raw quotient death_share / activity_share is asymmetric: "twice the share" is 2 but "half the share" is 0.5, so under- and over-representation of the same magnitude look very different and cannot be averaged or compared symmetrically. Taking log₂ makes the scale symmetric around 0 and scale-free: it does not depend on the size of the scope, so a value of +1 in the US 2024 scope means the same thing as +1 in any other scope (unlike percentile-point differences, whose meaning depends on how many entities are ranked). Base 2 was chosen for readability: each unit is a doubling.

# How to read ±1

Value Reading
0 the cancer's share of trials (or publications) equals its share of deaths
+1 it carries twice the share of deaths that its share of activity would suggest — half the activity per death of the scope average
+2 four times
−1 its share of activity is twice its share of deaths — twice the activity per death of the scope average
NULL undefined: no registered activity (activity < 1) or ineligible row

Equivalently, the ratio is log2( scope-average intensity / own intensity ): on the /research-gap scatter plots the dashed line is the scope's overall activity per death, and every cancer below it has a positive ratio. A change of one cancer's inputs changes every other cancer's ratio slightly, because the sums move.

# Inputs stored with every row

research_gap_components.inputs (jsonb) records the mortality observation id (and any duplicate observation ids for the same cancer/metric in the scope, e.g. other site definitions), the incidence observation id when present, the counters' updated_at, which source supplied the publication count, the literature count id and query when the fallback was used, the thresholds, the per-ratio eligibility flags, the scope sums and the activity source ids. Ranking rows repeat deaths, the activity, both shares, the sums, the observation id and the formula text, so /rankings/<slug> can answer "Why this rank?" without joining back.

# Caveats

  1. Descendant aggregation of trials. Trial counts include the cancer's NCIt descendants. A trial registered for "colorectal cancer" is attributed to the broader entity, not split between colon and rectum: narrower top-level sites can look under-trialled.
  2. Query-based literature counts. Publication counts come from a PubMed query per entity (stored on the row) and are not aggregated over descendants. A narrow or unlucky query yields a small count and a large positive Research Gap Ratio — a very high ratio should first prompt a check of the query.
  3. Registrants' condition phrasing. Whether a trial reaches a cancer depends on how sponsors phrase conditions and on reconciliation; unresolved labels are queued, not guessed.
  4. US-only burden. The share of deaths is a share of US deaths in one year and one source. The same cancer can have a different ratio under another source (cdc-wonder vs cdc-uscs differ in site definitions and coverage) or another year. Compare only within one table.
  5. Correlation ≠ under-funding. A positive ratio does not establish neglect: some cancers are hard to enrol, some are dominated by prevention or surgery rather than drug trials, some have large registries elsewhere. It is a signal, phrased as such ("signal, not accusation").
  6. Relative to the eligible set. Adding a cancer to the top-level set, or a cancer crossing the 100-deaths threshold, shifts every share.

# Differences from the percentile indexes

trial_gap / research_gap (ci-trial-gap-v1, ci-research-gap-v1) are percentile(mortality_count) − percentile(activity): rank-based, bounded to ±100 percentile points, insensitive to magnitudes (a 2× and a 20× mismatch can score the same) and dependent on the number of ranked entities. The ratio indexes are magnitude-based, symmetric and scale-free; they expose their inputs (shares, sums) directly and add the two intensities (trials_per_1000_deaths, publications_per_1000_deaths) that need no cross-entity comparison. Both families stay available: the percentile indexes for continuity, the ratio indexes as the primary reading on /research-gap.

# Recompute

text
pnpm cix counters   # refresh entity_counters
pnpm cix intel      # research_gap_components + the four ranking snapshots (with the other intelligence tables)