# 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. ```