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 · 150 lines markdown
Rendered Raw Blame History
1# Research Gap Index — methodology23*Formula versions: components `ci-research-gap-components-v1`; rankings `ci-trial-gap-ratio-v1`,4`ci-research-gap-ratio-v1`, `ci-trials-per-deaths-v1`, `ci-pubs-per-deaths-v1`. Code:5`packages/ranking/src/research-gap.ts` (unit tests in `packages/ranking/test/research-gap.test.ts`).6Table: `research_gap_components`. Web: `/research-gap`, `/rankings/trial_gap_ratio`,7`/rankings/research_gap_ratio`. API: `GET /v1/research-gap`, `GET /v1/research-gap/scopes`.*89## What it measures1011The Research Gap Index compares, inside **one burden scope**, each top-level cancer's **share of12deaths** with its **share of registered research activity**:1314- **active interventional trials** registered on ClinicalTrials.gov and mapped to the cancer or one of15  its NCIt descendants (`entity_counters.active_trial_count`);16- **publications of the last five years** in PubMed (`entity_counters.publication_count_5y`, falling17  back to `literature_counts` window `5y` when the counter is empty; the exact query is stored on the18  literature row and copied into `inputs`).1920It is a **computed metric** (claim category `computed_metric`). It says *where registered activity is21thin relative to mortality*. It does **not** say anything about the quality, cost, difficulty or22funding of research, and it inherits every limitation of the burden source (site definitions,23coding, completeness).2425## Scope2627A scope is a (geography, year, sex, burden source) combination in which at least **10 top-level28cancers** carry a `mortality_count` observation with `age_group = 'all'` — exactly the discovery rule29of the ranking engine (`packages/ranking/src/engine.ts`). Every sex present is a separate scope30(`all`, `male`, `female`). Scopes are rebuilt from scratch on each run (`pnpm cix intel`): the rows of31a scope are deleted and reinserted in one transaction, then four ranking snapshots are persisted32through the shared `persistSnapshot` so "Why this rank?", previous-rank deltas, TRACE and CSV exports33work like for any other metric.3435Burden today is **United States only** (CDC WONDER underlying-cause mortality; U.S. Cancer Statistics36NVSS mortality). IARC / GLOBOCAN is under license review, so no `WORLD` scope exists; a US death share37must not be read as a global death share.3839## Eligibility4041| Rule | Effect |42|---|---|43| `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` |44| no mortality observation | not stored (the scope is built from mortality observations) — the reason `no_mortality_observation` exists in the pure function for completeness |45| `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 Σ |46| `publications_5y ≥ 1` | same for `research_gap_ratio` |4748Per-1,000-deaths intensities are computed for every stored row (they need no cross-entity49denominator) but only eligible rows are ranked. Ranking confidence is `HIGH` when `deaths ≥ 1000`,50`MEDIUM` otherwise.5152Sex-specific scopes naturally produce zero-death rows (e.g. prostate in the female scope): they are53stored as ineligible, never dropped.5455## Shares5657All sums run over the eligible set `E` of the scope:5859```60death_share_i        = deaths_i        / Σ_{j∈E} deaths_j61trial_share_i        = active_trials_i / Σ_{j∈E} active_trials_j62publication_share_i  = publications_5y_i / Σ_{j∈E} publications_5y_j63```6465Shares are stored rounded to 6 decimals; the ratios below are computed from the unrounded shares (so66rounding never leaks into the index). Each share column sums to 1 over `E`.6768## Ratios and intensities6970```71trial_gap_ratio_i     = log2( death_share_i / trial_share_i )72research_gap_ratio_i  = log2( death_share_i / publication_share_i )73trials_per_1000_deaths_i        = active_trials_i   / (deaths_i / 1000)74publications_per_1000_deaths_i  = publications_5y_i / (deaths_i / 1000)75```7677Ratios are rounded to 6 decimals, intensities to 4. Rankings sort **descending**: rank 1 = largest78positive gap.7980### Why log₂8182The raw quotient `death_share / activity_share` is asymmetric: "twice the share" is 2 but "half the83share" is 0.5, so under- and over-representation of the same magnitude look very different and cannot84be averaged or compared symmetrically. Taking log₂ makes the scale **symmetric around 0** and85**scale-free**: it does not depend on the size of the scope, so a value of +1 in the US 2024 scope86means the same thing as +1 in any other scope (unlike percentile-point differences, whose meaning87depends on how many entities are ranked). Base 2 was chosen for readability: each unit is a doubling.8889### How to read ±19091| Value | Reading |92|---|---|93| `0` | the cancer's share of trials (or publications) equals its share of deaths |94| `+1` | it carries **twice** the share of deaths that its share of activity would suggest — half the activity per death of the scope average |95| `+2` | four times |96| `−1` | its share of activity is twice its share of deaths — twice the activity per death of the scope average |97| `NULL` | undefined: no registered activity (`activity < 1`) or ineligible row |9899Equivalently, the ratio is `log2( scope-average intensity / own intensity )`: on the `/research-gap`100scatter plots the dashed line is the scope's overall activity per death, and every cancer below it101has a positive ratio. A change of one cancer's inputs changes every other cancer's ratio slightly,102because the sums move.103104## Inputs stored with every row105106`research_gap_components.inputs` (jsonb) records the mortality observation id (and any duplicate107observation ids for the same cancer/metric in the scope, e.g. other site definitions), the incidence108observation id when present, the counters' `updated_at`, which source supplied the publication count,109the literature count id and query when the fallback was used, the thresholds, the per-ratio110eligibility flags, the scope sums and the activity source ids. Ranking rows repeat deaths, the111activity, both shares, the sums, the observation id and the formula text, so `/rankings/<slug>` can112answer "Why this rank?" without joining back.113114## Caveats1151161. **Descendant aggregation of trials.** Trial counts include the cancer's NCIt descendants. A trial117   registered for "colorectal cancer" is attributed to the broader entity, not split between colon and118   rectum: narrower top-level sites can look under-trialled.1192. **Query-based literature counts.** Publication counts come from a PubMed query per entity (stored120   on the row) and are *not* aggregated over descendants. A narrow or unlucky query yields a small121   count and a large positive Research Gap Ratio — a very high ratio should first prompt a check of122   the query.1233. **Registrants' condition phrasing.** Whether a trial reaches a cancer depends on how sponsors phrase124   conditions and on reconciliation; unresolved labels are queued, not guessed.1254. **US-only burden.** The share of deaths is a share of *US* deaths in one year and one source. The126   same cancer can have a different ratio under another source (`cdc-wonder` vs `cdc-uscs` differ in127   site definitions and coverage) or another year. Compare only within one table.1285. **Correlation ≠ under-funding.** A positive ratio does not establish neglect: some cancers are hard129   to enrol, some are dominated by prevention or surgery rather than drug trials, some have large130   registries elsewhere. It is a signal, phrased as such ("signal, not accusation").1316. **Relative to the eligible set.** Adding a cancer to the top-level set, or a cancer crossing the132   100-deaths threshold, shifts every share.133134## Differences from the percentile indexes135136`trial_gap` / `research_gap` (`ci-trial-gap-v1`, `ci-research-gap-v1`) are137`percentile(mortality_count) − percentile(activity)`: rank-based, bounded to ±100 percentile points,138insensitive to magnitudes (a 2× and a 20× mismatch can score the same) and dependent on the number of139ranked entities. The ratio indexes are **magnitude-based, symmetric and scale-free**; they expose their140inputs (shares, sums) directly and add the two intensities (`trials_per_1000_deaths`,141`publications_per_1000_deaths`) that need no cross-entity comparison. Both families stay available:142the percentile indexes for continuity, the ratio indexes as the primary reading on `/research-gap`.143144## Recompute145146```147pnpm cix counters   # refresh entity_counters148pnpm cix intel      # research_gap_components + the four ranking snapshots (with the other intelligence tables)149```150