| # 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 |
| |
| ```python |
| 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: |
| |
| ```python |
| 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. |
| |
| ```python |
| 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 |
| |
| ```python |
| 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: |
| |
| ```python |
| 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. |
| |
| ```python |
| 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 *và* VQA type *và* 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. |
| ``` |
|
|