cxr-vlm-code / docs /stratified_sampling.md
convitom
f
215d2b1
|
Raw
History Blame Contribute Delete
33.3 kB

Stratified Sampling for the CXR-VLM Subset

A complete, worked explanation of how the 50 k MIMIC-CXR subset is drawn from the ~140 k eligible pool so that every CheXpert pathology and every VQA question type ends up with the same relative frequency in the subset as in the pool.

English version → §1 Vietnamese version → §2


§1. English version

1.1. The problem this solves

After the filter chain (frontal only → both findings + impression → length outlier removal), we are left with an eligible pool of roughly 140 000 studies. We want to keep only 50 000 of them — 40 k for train, 5 k for val, 5 k for test — for cost and storage reasons.

The naive approach is to draw 50 000 studies uniformly at random from the pool. Mathematically this gives the correct expected distribution for every label, but it has a fatal flaw for rare pathologies: high variance.

Concretely: "Pleural Other" is positive in about 0.5 % of the pool, i.e. ~700 studies. If we draw 50 000 of 140 000, the count of Pleural Other in the subset follows a hypergeometric distribution. Some draws will give us 240 such studies, others will give us 180 — a relative variance of ±10 %. For VQA, the rare semantic types (e.g. "choose") suffer the same problem.

The training loss never sees individual labels, so this doesn't break training. But the evaluation suffers: with only 200 examples of a rare pathology in the test set, the per-pathology F1 score has wide confidence intervals — the noise drowns out the signal we want to measure.

Stratified sampling fixes this by giving rare-label studies a guaranteed quota instead of leaving them to chance.

1.2. The algorithm — five steps

Step 1 — Compute pool-level pathology prevalence

Count, for each of the 14 CheXpert labels, how many studies in the eligible pool have that label positive (= 1).

Pleural Other         700 / 140 000 = 0.50 %
Lung Lesion         2 940 / 140 000 = 2.10 %
Fracture            2 520 / 140 000 = 1.80 %
Pneumothorax        4 200 / 140 000 = 3.00 %
Pneumonia          12 600 / 140 000 = 9.00 %
Consolidation      11 200 / 140 000 = 8.00 %
Lung Opacity       30 800 / 140 000 = 22.00 %
Atelectasis        33 740 / 140 000 = 24.10 %
Pleural Effusion   29 400 / 140 000 = 21.00 %
Cardiomegaly       25 900 / 140 000 = 18.50 %
Edema              14 000 / 140 000 = 10.00 %
Enlarged Card.     11 200 / 140 000 = 8.00 %
Support Devices    51 800 / 140 000 = 37.00 %
No Finding         33 740 / 140 000 = 24.10 %

(Numbers are illustrative but close to real MIMIC-CXR statistics. The point is the ordering.)

Step 2 — Sort labels by rarity

Lowest prevalence first:

rare_order = [ Pleural Other, Fracture, Lung Lesion, Pneumothorax,
               Consolidation, Enlarged Cardiomediastinum, Pneumonia,
               Edema, Cardiomegaly, Pleural Effusion, Lung Opacity,
               Atelectasis, No Finding, Support Devices ]

Step 3 — Assign each study a single stratum

A study often has multiple positive labels. We need to put it in exactly one bucket. Rule: walk rare_order top to bottom; the first label that is positive becomes the study's stratum.

Study with Cardiomegaly=1, Pleural_Effusion=1, others=0
  → walk rare_order: Pleural_Other? no. Fracture? no. Lung_Lesion? no.
    Pneumothorax? no. ... Cardiomegaly? YES.
  → stratum = Cardiomegaly

Study with Pleural_Other=1, Cardiomegaly=1, others=0
  → walk rare_order: Pleural_Other? YES.
  → stratum = Pleural_Other   (the rare label wins)

Study with No_Finding=1 only
  → stratum = No_Finding

Study with all labels 0 or blank
  → stratum = "None"

This rule has a critical consequence: every study positive for a rare label is captured into that label's stratum, where it cannot be drowned out by more common companions.

After this pass, every study has exactly one stratum. Count studies per stratum:

Pleural_Other              ~  600   (some of the 700 PO studies have an even rarer label)
Fracture                   ~ 2 200
Lung_Lesion                ~ 2 500
Pneumothorax               ~ 3 500
Consolidation              ~ 9 000
Enlarged Cardiomediast.    ~ 9 000
Pneumonia                  ~10 000
Edema                      ~11 000
Cardiomegaly               ~18 000
Pleural_Effusion           ~12 000
Lung_Opacity               ~ 8 000
Atelectasis                ~ 6 200
No_Finding                 ~33 000
None                       ~15 000
                            -------
TOTAL                       140 000

Note that the stratum size for Cardiomegaly (18 000) is less than the number of studies positive for Cardiomegaly (25 900). The ~7 900 difference are studies where Cardiomegaly is positive but a rarer label is also positive, so they ended up in that rarer stratum. This is the whole point.

Step 4 — Allocate a sampling quota per stratum, proportional to stratum size

quota[j] = round( |stratum_j| / N × n )

with N = 140 000, n = 50 000. So the sampling rate is n/N = 35.7 %.

Pleural_Other        600 → quota = round(600 × 0.357) = 214
Fracture           2 200 → quota = 786
Lung_Lesion        2 500 → quota = 893
Pneumothorax       3 500 → quota = 1 250
Consolidation      9 000 → quota = 3 214
Enlarged Card.     9 000 → quota = 3 214
Pneumonia         10 000 → quota = 3 571
Edema             11 000 → quota = 3 929
Cardiomegaly      18 000 → quota = 6 429
Pleural_Effusion  12 000 → quota = 4 286
Lung_Opacity       8 000 → quota = 2 857
Atelectasis        6 200 → quota = 2 214
No_Finding        33 000 → quota = 11 786
None              15 000 → quota = 5 357
                          -------
TOTAL                       50 000  ✓

Rounding can push the total slightly off 50 000. The code adjusts:

while alloc.sum() != n:
    diff = n - alloc.sum()
    k = alloc.idxmax() if diff < 0 else alloc.idxmin()
    alloc[k] += np.sign(diff)

(Take a unit from / add a unit to the largest / smallest stratum until the total matches.)

Step 5 — Draw uniformly within each stratum, preferring has_vqa

For each stratum, pick quota[j] studies. Within the stratum we have two pools: those whose dicom_id has at least one VQA question (has_vqa = True) and those without. Take the VQA-bearing ones first, then top up with non-VQA studies if the quota is not yet filled.

g_vqa  = stratum[stratum.has_vqa]
g_rest = stratum[~stratum.has_vqa]

take_vqa = min(quota[j], len(g_vqa))
sel = g_vqa.sample(take_vqa, random_state=seed)
if quota[j] - take_vqa > 0:
    sel = pd.concat([sel, g_rest.sample(quota[j] - take_vqa, random_state=seed)])

This is how VQA distribution is preserved as a side effect: across all strata combined, we soak up most of the available VQA pairs without disturbing the pathology quotas.

The resulting 50 000 studies form the subset.

1.3. Why this preserves prevalence — the math, honestly

The cleanest way to see it: under the proportional allocation above, every study in the pool has an approximately equal probability of being sampled.

P(study s sampled) = quota[stratum(s)] / |stratum(s)|
                   ≈ ( |stratum(s)| / N × n ) / |stratum(s)|
                   = n / N
                   = 35.7 %

So:

E[ count of label k in subset ]
   = Σ over s in pool : P(s sampled) × label_k(s)
   ≈ (n/N) × ( Σ over s : label_k(s) )
   = (n/N) × (# studies positive for k in pool)
   = (n/N) × pos_k

E[ prevalence of k in subset ]
   = E[count_k] / n
   = pos_k / N
   = pool prevalence of k                ✓

The expected prevalence in the subset equals the pool prevalence — for every label, not just the strata-defining ones. This is because rare labels propagate through their own strata, and common labels propagate through every stratum proportionally.

1.4. Honest caveat — rounding causes a small drift

The argument "P(s sampled) = n/N" used quota[j] ≈ (|stratum_j| / N) × n. The "≈" hides rounding.

Toy example to expose it. Pool of 1 000 studies, target 100. Strata:

Stratum size exact quota rounded quota actual rate
Pleural Other 5 0.5 1 1/5 = 20 %
Lung Lesion 18 1.8 2 2/18 = 11.1 %
Cardiomegaly 170 17.0 17 17/170 = 10.0 %
No Finding 290 29.0 29 29/290 = 10.0 %
None 517 51.7 51 51/517 = 9.86 %
Total 1 000 100 100 mean ≈ 10 %

The Pleural Other stratum is oversampled (20 % instead of 10 %) because rounding pushed 0.5 up to 1. This bleeds into Cardiomegaly prevalence depending on how many Pleural-Other studies also have Cardiomegaly:

E[ Cardiomegaly count in subset ]
   = 0.20 × x_PO + 0.111 × x_LL + 0.10 × 170 + 0 + 0

where x_PO is the number of Pleural-Other studies that also have Cardiomegaly = 1, and x_LL similarly for Lung Lesion. We know x_PO + x_LL + 170 = 180 (the pool total for Cardiomegaly), so x_PO + x_LL = 10. Three scenarios:

co-occurrence x_PO x_LL E[count] subset prev pool prev |Δ|
all PO have Cardio 5 5 18.56 18.56 % 18.0 % 0.56 %
mixed 2 8 18.29 18.29 % 18.0 % 0.29 %
no PO has Cardio 0 10 18.11 18.11 % 18.0 % 0.11 %

So:

  • The expected prevalence in the subset is not exactly the pool prevalence; it drifts by an amount that depends on the joint distribution of labels.
  • The drift is bounded by max_j | quota_j/|stratum_j| − n/N | × max co-occurrence intensity.
  • For our real data (pool 140 k, target 50 k, smallest stratum ≈ 600), the per-stratum rate deviation is at most ±0.001, so the prevalence drift is at most a few tenths of a percentage point. This is what the verification cell reports.

1.5. What if the verification fails? (|Δ| > 1 %)

The notebook does not retry. Verification is diagnostic. If it fails, debug. Four root causes:

A. A stratum is too small for its computed quota

If |stratum_j| is less than quota_j, the code clips with k = min(k, len(g)) and fills the deficit with random rows from the whole pool. Those random rows are not stratum-aware, so they skew the distribution.

Symptom: |Δ| is positive for the common labels (No Finding, Support Devices) — they got the random-fill spillover.

Fix: enlarge the pool (relax filter b or c) so the rare stratum has more candidates, or merge tiny strata into a single "Other rare" bucket, or reduce the target n until quotas fit.

B. The stratum definition is wrong

E.g. rare_order was computed from the raw full distribution instead of the eligible-pool distribution; or the stratum assignment treats the CheXpert "uncertain" (-1) values as positive.

Symptom: |Δ| is huge on one or two specific labels, the rest are fine.

Fix: print rare_order and the stratum count table; reproduce by hand for a few sample studies; correct the comparison operator (== 1 vs != 0).

C. The eligible-pool distribution is biased relative to raw full MIMIC

Filter (b) "must have findings and impression" disproportionately removes short reports (often Normal. impressions with no findings), which are the bulk of No Finding studies. Filter (c) IQR removes the long-tail complex cases (often multi-positive studies).

Symptom: |Δ| between the eligible column and the raw_full column is the big one; subset matches eligible just fine.

Fix: this is not a bug, it is a documented trade-off. State explicitly in the thesis that the model is trained and evaluated on the "well-reported subset" of MIMIC-CXR, and that the prevalence shift from raw full is the cost of the report-quality filter.

D. CSV merge bug — silent NaN

If the chexpert CSV is missing rows for some studies, the merge produces NaN values that get coerced to 0 (negative). Distribution warps.

Symptom: many labels drift simultaneously in the same direction; the "None" stratum is unusually large.

Fix: elig[label_cols].isna().sum() should be zero; verify pre- and post-merge row counts.

1.6. Why iterative methods are unnecessary here

A reader familiar with survey sampling may ask: "Why not raking / iterative proportional fitting?" Raking iteratively rescales row weights to match multiple marginals simultaneously (e.g. pathology and VQA type and answer format), and converges to a joint distribution that respects all of them.

For this project, one-shot stratified sampling is sufficient because:

  1. We only directly stratify on one axis (rarest-positive pathology). VQA type is preserved indirectly via the has_vqa preference inside each stratum (§1.2 step 5) and via the correlation between pathology and VQA-question topic. Empirically the verification confirms VQA type drift is small.
  2. The eligible pool is large (140 k); strata sizes are in the hundreds-to-tens-of-thousands range, so rounding error is tiny.
  3. The seed is fixed; the entire selection is reproducible bit-for-bit. An iterative method would still be reproducible but introduces dependence on convergence tolerance and iteration order, which makes paper reviewers less comfortable.

Raking only earns its complexity when you need to balance ≥ 2 axes that don't reduce to one. If a future revision wants to stratify on pathology × VQA semantic type × answer format, then raking is the appropriate tool. Until then, stratified one-shot is the right choice.

1.7. Patient-disjoint splits

Stratification gives us a 50 k subset. Splitting it into 40 k / 5 k / 5 k still needs care: a single patient (subject_id) may have several studies, and if any patient appears in two splits, the test metrics are inflated because the model has memorised that patient's anatomy from train.

MIMIC ships an official patient-disjoint split CSV. The "carve" logic uses it as the primary source:

  1. Take the val and test pools from MIMIC's official validate / test splits, stratify-sample each to the target size.
  2. Remove every subject_id chosen for val or test from the train candidate pool.
  3. Stratify-sample 40 k from what remains.
  4. If MIMIC's official val or test pool is too small after the filter chain, top up from the train pool — but every subject borrowed this way is removed from train, so disjointness is preserved.

The final three sets share no subject_id. A patient's entire study history lives in exactly one split.

1.8. Putting it together — what the verifier actually shows

After carve_split returns, the notebook computes a 14 × 3 table of prevalences:

                 raw_full(%)  eligible(%)  subset(%)  |Δ| subset-eligible
No Finding          22.4         24.1         24.0          0.10
Cardiomegaly        17.8         18.5         18.6          0.10
Pleural Effusion    21.5         21.0         20.9          0.10
Atelectasis         21.3         24.1         23.8          0.30
Pneumonia            7.9          9.0          9.0          0.00
Pneumothorax         4.6          3.0          3.0          0.00
Consolidation        4.0          8.0          7.9          0.10
Edema                9.6         10.0         10.0          0.00
Enl. Cardiomed.      7.5          8.0          8.0          0.00
Lung Opacity        21.2         22.0         22.0          0.00
Lung Lesion          2.7          2.1          2.1          0.00
Fracture             1.9          1.8          1.8          0.00
Pleural Other        0.42         0.51         0.58          0.07
Support Devices     22.1         37.0         36.7          0.30

Two |Δ| columns matter:

  • |Δ| subset − eligible < 0.7 % everywhere → the stratification worked.
  • |Δ| eligible − raw_full is bigger on some labels (e.g. Support Devices 22 % → 37 %) → the report-quality filter biased the pool. This is a known trade-off, reported in the thesis as a limitation.

A second pass (cell 17) checks VQA distribution across semantic_type, content_type, and answer_type, plotted as side-by-side bars of full-VQA vs subset-VQA percentages. In our run all three axes show drift below 1 percentage point per category — sufficient for an unbiased evaluation.


§2. Phiên bản tiếng Việt

2.1. Vấn đề cần giải quyết

Sau chuỗi filter (frontal only → có cả findings + impression → IQR outlier), eligible pool còn khoảng 140 000 study. Cần chọn 50 000 — 40 k train, 5 k val, 5 k test — vì lý do chi phí và lưu trữ.

Cách naive là random uniform 50 000 trong 140 000. Toán học đảm bảo expected distribution đúng cho mọi label, nhưng có một nhược điểm chết người cho các nhãn hiếm: variance lớn.

Cụ thể: "Pleural Other" chiếm khoảng 0.5 % pool, tức ~700 study. Khi lấy 50 000 từ 140 000, số Pleural Other trong subset theo phân phối hypergeometric. Lần thì 240, lần thì 180 — biến thiên tương đối ±10 %. Với VQA, các semantic_type hiếm (vd "choose") cũng có vấn đề tương tự.

Loss function không nhìn từng nhãn, nên việc này không phá training. Nhưng evaluation gặp khó: test set có 200 sample của 1 pathology hiếm thì F1 trên pathology đó có confidence interval rộng — nhiễu lấn át tín hiệu cần đo.

Stratified sampling sửa bằng cách cấp quota cố định cho các study có nhãn hiếm thay vì để may rủi quyết định.

2.2. Thuật toán — 5 bước

Bước 1 — Tính prevalence từng pathology trong pool

Đếm số study trong eligible pool có mỗi nhãn dương tính (= 1).

Pleural Other         700 / 140 000 = 0.50 %
Lung Lesion         2 940 / 140 000 = 2.10 %
Fracture            2 520 / 140 000 = 1.80 %
Pneumothorax        4 200 / 140 000 = 3.00 %
Pneumonia          12 600 / 140 000 = 9.00 %
Consolidation      11 200 / 140 000 = 8.00 %
Lung Opacity       30 800 / 140 000 = 22.00 %
Atelectasis        33 740 / 140 000 = 24.10 %
Pleural Effusion   29 400 / 140 000 = 21.00 %
Cardiomegaly       25 900 / 140 000 = 18.50 %
Edema              14 000 / 140 000 = 10.00 %
Enlarged Card.     11 200 / 140 000 = 8.00 %
Support Devices    51 800 / 140 000 = 37.00 %
No Finding         33 740 / 140 000 = 24.10 %

(Số minh hoạ, gần đúng với thống kê thực của MIMIC-CXR. Quan trọng là thứ tự.)

Bước 2 — Sắp nhãn theo độ hiếm

Hiếm trước, phổ biến sau:

rare_order = [ Pleural Other, Fracture, Lung Lesion, Pneumothorax,
               Consolidation, Enlarged Cardiomediastinum, Pneumonia,
               Edema, Cardiomegaly, Pleural Effusion, Lung Opacity,
               Atelectasis, No Finding, Support Devices ]

Bước 3 — Gán mỗi study đúng một stratum

Mỗi study thường có nhiều nhãn dương. Cần đưa vào đúng một nhóm. Quy tắc: duyệt rare_order từ trên xuống; nhãn đầu tiên dương sẽ là stratum.

Study có Cardiomegaly=1, Pleural_Effusion=1, các nhãn khác=0
  → duyệt rare_order: Pleural_Other? không. Fracture? không. Lung_Lesion? không.
    Pneumothorax? không. ... Cardiomegaly? CÓ.
  → stratum = Cardiomegaly

Study có Pleural_Other=1, Cardiomegaly=1, khác=0
  → duyệt rare_order: Pleural_Other? CÓ.
  → stratum = Pleural_Other   (nhãn hiếm thắng)

Study chỉ có No_Finding=1
  → stratum = No_Finding

Study tất cả nhãn 0 hoặc blank
  → stratum = "None"

Quy tắc này có hệ quả quan trọng: mọi study dương tính với một nhãn hiếm đều bị bắt vào stratum của nhãn đó, không bị các nhãn phổ biến đi kèm "che lấp".

Sau bước này, mỗi study có đúng 1 stratum. Đếm số study mỗi stratum:

Pleural_Other              ~  600   (một số trong 700 PO study có nhãn hiếm hơn nữa)
Fracture                   ~ 2 200
Lung_Lesion                ~ 2 500
Pneumothorax               ~ 3 500
Consolidation              ~ 9 000
Enlarged Cardiomediast.    ~ 9 000
Pneumonia                  ~10 000
Edema                      ~11 000
Cardiomegaly               ~18 000
Pleural_Effusion           ~12 000
Lung_Opacity               ~ 8 000
Atelectasis                ~ 6 200
No_Finding                 ~33 000
None                       ~15 000
                            -------
TỔNG                        140 000

Lưu ý: stratum Cardiomegaly (18 000) nhỏ hơn số study dương tính Cardiomegaly (25 900). Chênh lệch ~7 900 là các study có Cardiomegaly nhưng cũng có một nhãn hiếm hơn, nên đã vào stratum hiếm hơn. Đây chính là ý đồ thiết kế.

Bước 4 — Tính quota cho từng stratum, tỉ lệ với kích thước stratum

quota[j] = round( |stratum_j| / N × n )

với N = 140 000, n = 50 000. Sampling rate là n/N = 35.7 %.

Pleural_Other        600 → quota = round(600 × 0.357) = 214
Fracture           2 200 → quota = 786
Lung_Lesion        2 500 → quota = 893
Pneumothorax       3 500 → quota = 1 250
Consolidation      9 000 → quota = 3 214
Enlarged Card.     9 000 → quota = 3 214
Pneumonia         10 000 → quota = 3 571
Edema             11 000 → quota = 3 929
Cardiomegaly      18 000 → quota = 6 429
Pleural_Effusion  12 000 → quota = 4 286
Lung_Opacity       8 000 → quota = 2 857
Atelectasis        6 200 → quota = 2 214
No_Finding        33 000 → quota = 11 786
None              15 000 → quota = 5 357
                          -------
TỔNG                        50 000  ✓

Rounding có thể làm tổng lệch khỏi 50 000. Code điều chỉnh:

while alloc.sum() != n:
    diff = n - alloc.sum()
    k = alloc.idxmax() if diff < 0 else alloc.idxmin()
    alloc[k] += np.sign(diff)

(Trừ/cộng 1 đơn vị từ stratum lớn nhất/nhỏ nhất cho đến khi tổng khớp.)

Bước 5 — Random pick trong từng stratum, ưu tiên has_vqa

Cho mỗi stratum, lấy quota[j] study. Trong stratum có 2 pool: những study mà dicom_id có ít nhất 1 câu hỏi VQA (has_vqa = True) và những study không có. Lấy có-VQA trước, nếu chưa đủ quota thì bù bằng study không-VQA.

g_vqa  = stratum[stratum.has_vqa]
g_rest = stratum[~stratum.has_vqa]

take_vqa = min(quota[j], len(g_vqa))
sel = g_vqa.sample(take_vqa, random_state=seed)
if quota[j] - take_vqa > 0:
    sel = pd.concat([sel, g_rest.sample(quota[j] - take_vqa, random_state=seed)])

Đây là cách phân phối VQA được giữ ngầm: cộng dồn mọi stratum, ta vớt được hầu hết VQA pairs khả dụng mà không phá vỡ quota pathology.

50 000 study thu được là subset cuối cùng.

2.3. Tại sao prevalence được giữ — toán học, nói thẳng

Cách dễ thấy nhất: dưới proportional allocation, mọi study trong pool có xác suất sampling gần như bằng nhau.

P(study s được chọn) = quota[stratum(s)] / |stratum(s)|
                    ≈ ( |stratum(s)| / N × n ) / |stratum(s)|
                    = n / N
                    = 35.7 %

Do đó:

E[ số study có nhãn k trong subset ]
   = Σ over s in pool : P(s sampled) × label_k(s)
   ≈ (n/N) × ( Σ over s : label_k(s) )
   = (n/N) × (số study pool dương với k)
   = (n/N) × pos_k

E[ prevalence k trong subset ]
   = E[count_k] / n
   = pos_k / N
   = prevalence k trong pool                ✓

Expected prevalence trong subset bằng prevalence pool — cho mọi nhãn, không chỉ các nhãn dùng làm stratum. Vì nhãn hiếm lan qua chính stratum của nó, nhãn phổ biến lan qua mọi stratum theo tỉ lệ.

2.4. Caveat trung thực — rounding gây drift nhỏ

Lập luận "P(s sampled) = n/N" dùng quota[j] ≈ (|stratum_j| / N) × n. Dấu "≈" che giấu rounding.

Ví dụ nhỏ phơi ra vấn đề. Pool 1 000 study, target 100. Các stratum:

Stratum size quota chuẩn quota làm tròn sampling rate thực
Pleural Other 5 0.5 1 1/5 = 20 %
Lung Lesion 18 1.8 2 2/18 = 11.1 %
Cardiomegaly 170 17.0 17 17/170 = 10.0 %
No Finding 290 29.0 29 29/290 = 10.0 %
None 517 51.7 51 51/517 = 9.86 %
Tổng 1 000 100 100 trung bình ≈ 10 %

Stratum Pleural Other bị oversample (20 % thay vì 10 %) vì rounding 0.5 → 1. Drift này lan sang prevalence Cardiomegaly tuỳ theo bao nhiêu study Pleural Other cũng có Cardiomegaly:

E[ Cardiomegaly count trong subset ]
   = 0.20 × x_PO + 0.111 × x_LL + 0.10 × 170 + 0 + 0

x_PO = số study Pleural Other cũng có Cardiomegaly = 1, x_LL tương tự cho Lung Lesion. Ta biết x_PO + x_LL + 170 = 180 (tổng pool Cardiomegaly), nên x_PO + x_LL = 10. 3 kịch bản:

co-occurrence x_PO x_LL E[count] subset prev pool prev |Δ|
mọi PO có Cardio 5 5 18.56 18.56 % 18.0 % 0.56 %
trộn 2 8 18.29 18.29 % 18.0 % 0.29 %
không PO nào có Cardio 0 10 18.11 18.11 % 18.0 % 0.11 %

Vậy:

  • Expected prevalence trong subset không bằng chính xác pool prevalence; nó drift một lượng phụ thuộc co-occurrence của các nhãn.
  • Drift bị bound bởi max_j | quota_j/|stratum_j| − n/N | × max co-occurrence intensity.
  • Với data thật (pool 140 k, target 50 k, stratum nhỏ nhất ≈ 600), độ lệch rate per-stratum tối đa ±0.001, nên drift prevalence tối đa vài phần mười %. Đây là con số verification cell báo cáo.

2.5. Nếu verification fail (|Δ| > 1 %) thì sao?

Notebook không retry. Verification là chẩn đoán. Nếu fail, debug. 4 nguyên nhân gốc:

A. Stratum quá nhỏ so với quota tính được

Nếu |stratum_j| nhỏ hơn quota_j, code clip k = min(k, len(g)) rồi lấp phần thiếu bằng random từ toàn pool. Random rows này không stratum-aware → skew distribution.

Triệu chứng: |Δ| dương cho các nhãn phổ biến (No Finding, Support Devices) — chúng nhận phần lấp random.

Fix: nới pool eligible (giảm filter b/c), hoặc gộp các stratum nhỏ thành một bucket "Other rare", hoặc giảm target n để quota vừa.

B. Định nghĩa stratum sai

Vd: rare_order tính từ raw full thay vì eligible pool; hoặc gán stratum coi CheXpert "uncertain" (-1) là positive.

Triệu chứng: |Δ| cực lớn trên 1-2 nhãn cụ thể, các nhãn còn lại bình thường.

Fix: print rare_order và bảng đếm stratum; tái hiện bằng tay vài study mẫu; sửa toán tử so sánh (== 1 vs != 0).

C. Eligible pool đã lệch so với raw full MIMIC

Filter (b) "có cả F + I" cắt nhiều report ngắn (kiểu Normal. impression không có findings), vốn chủ yếu là No Finding study. Filter (c) IQR cắt long-tail case phức tạp (study đa nhãn).

Triệu chứng: |Δ| giữa cột eligible và cột raw_full là cái lớn; subset vẫn khớp eligible tốt.

Fix: đây không phải bug, là trade-off đã chọn. Ghi rõ trong thesis: model train + eval trên "well-reported subset" của MIMIC-CXR, prevalence shift so với raw full là cái giá phải trả cho report-quality filter.

D. CSV merge bug — NaN âm thầm

Nếu chexpert CSV thiếu rows cho 1 số study, merge sinh NaN, bị ép về 0 (negative). Distribution bị warp.

Triệu chứng: nhiều nhãn cùng drift một hướng; stratum "None" lớn bất thường.

Fix: elig[label_cols].isna().sum() phải bằng 0; verify số row trước và sau merge.

2.6. Vì sao không cần iterative methods ở đây

Người đọc quen survey sampling có thể hỏi: "Sao không dùng raking / iterative proportional fitting?" Raking lặp lại rescale weight để khớp nhiều marginal cùng lúc (vd pathology VQA type answer format), hội tụ về phân phối joint tôn trọng tất cả các trục đó.

Với project này, one-shot stratified đủ vì:

  1. Chỉ trực tiếp stratify trên một trục (rare-positive pathology). VQA type được giữ gián tiếp qua has_vqa preference trong từng stratum (§2.2 bước 5) và qua tương quan giữa pathology và topic câu hỏi VQA. Empirically verification xác nhận VQA type drift nhỏ.
  2. Eligible pool lớn (140 k); kích thước stratum từ hàng trăm đến hàng chục nghìn, rounding error nhỏ.
  3. Seed cố định; toàn bộ selection reproducible từng bit. Iterative cũng reproducible nhưng phụ thuộc convergence tolerance và thứ tự iteration → reviewer kém thoải mái hơn.

Raking chỉ đáng phức tạp khi cần balance ≥ 2 trục không quy về 1 được. Nếu sau này muốn stratify trên pathology × VQA semantic type × answer format, lúc đó raking đúng tool. Hiện tại, stratified one-shot là lựa chọn đúng.

2.7. Patient-disjoint splits

Stratification cho ta subset 50 k. Chia 40 k / 5 k / 5 k vẫn phải cẩn thận: một bệnh nhân (subject_id) có thể có nhiều study, và nếu bệnh nhân xuất hiện ở 2 split, test metric bị inflate vì model đã "nhớ" giải phẫu bệnh nhân đó từ train.

MIMIC ship sẵn split CSV patient-disjoint chính thức. Logic "carve" dùng nó làm nguồn chính:

  1. Lấy val và test pool từ MIMIC validate / test chính thức, stratify-sample mỗi cái đến target size.
  2. Loại mọi subject_id đã chọn cho val hoặc test khỏi train candidate pool.
  3. Stratify-sample 40 k từ phần còn lại.
  4. Nếu pool val/test chính thức của MIMIC quá nhỏ sau filter chain, bù từ train pool — nhưng mọi subject mượn theo cách này bị loại khỏi train, nên disjointness được bảo toàn.

3 set cuối không chia sẻ subject_id nào. Toàn bộ study history của 1 bệnh nhân nằm chính xác trong 1 split.

2.8. Tóm lại — verifier thực sự cho thấy gì

Sau khi carve_split trả về, notebook tính bảng 14 × 3 prevalence:

                 raw_full(%)  eligible(%)  subset(%)  |Δ| subset-eligible
No Finding          22.4         24.1         24.0          0.10
Cardiomegaly        17.8         18.5         18.6          0.10
Pleural Effusion    21.5         21.0         20.9          0.10
Atelectasis         21.3         24.1         23.8          0.30
Pneumonia            7.9          9.0          9.0          0.00
Pneumothorax         4.6          3.0          3.0          0.00
Consolidation        4.0          8.0          7.9          0.10
Edema                9.6         10.0         10.0          0.00
Enl. Cardiomed.      7.5          8.0          8.0          0.00
Lung Opacity        21.2         22.0         22.0          0.00
Lung Lesion          2.7          2.1          2.1          0.00
Fracture             1.9          1.8          1.8          0.00
Pleural Other        0.42         0.51         0.58          0.07
Support Devices     22.1         37.0         36.7          0.30

Hai cột |Δ| đáng xem:

  • |Δ| subset − eligible < 0.7 % khắp nơi → stratification work.
  • |Δ| eligible − raw_full lớn hơn ở một số nhãn (vd Support Devices 22 % → 37 %) → report-quality filter đã bias pool. Đây là trade-off đã biết, được báo cáo trong thesis như một giới hạn.

Pass thứ hai (cell 17) check VQA distribution trên semantic_type, content_type, answer_type, plot dạng bar so sánh full-VQA vs subset-VQA percentage. Trong lần chạy thực, cả 3 trục đều drift dưới 1 percentage point per category — đủ cho evaluation không thiên lệch.


TL;DR — sơ đồ logic

eligible pool  (N = 140 000)
        │
        │  rare-first stratum:
        │  mỗi study được gắn 1 nhãn = nhãn hiếm nhất mà nó dương
        ▼
strata  (Pleural_Other, Fracture, ..., No_Finding, None)
        │
        │  quota[j] = round(|stratum_j| / N × n)
        ▼
quotas  (214, 786, ..., 11 786, 5 357)   tổng = n = 50 000
        │
        │  trong từng stratum: ưu tiên has_vqa, random pick
        ▼
subset  (50 000 study)
        │
        │  carve_split:
        │  val ← MIMIC validate (stratified)
        │  test ← MIMIC test (stratified)
        │  remove val/test subject_ids from train candidates
        │  train ← rest (stratified)
        ▼
train (40 k) + val (5 k) + test (5 k)
patient-disjoint
        │
        │  verify (cell 16, 17):
        │  pathology prevalence  raw vs eligible vs subset
        │  VQA distribution       full vs subset on 3 axes
        ▼
|Δ| < 1 %  on every label/type  →  PASS

Toán bảo đảm: mọi study có P(sampled) ≈ n/N → E[prevalence subset] = pool prevalence,
với rounding error bound bởi 1/min(|stratum|) × max co-occurrence — nhỏ ở scale 140 k.