File size: 33,330 Bytes
215d2b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
# 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.
```