cvic 1.2.2__tar.gz → 1.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cvic
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Summary: Local hyperparameter optimization of image classifiers using cross-validation + Ray Tune + timm
5
5
  Project-URL: Homepage, https://github.com/ljbuturovic/cvic
6
6
  Project-URL: Repository, https://github.com/ljbuturovic/cvic
@@ -156,67 +156,92 @@ def _split_by_label(by_label, training_fraction: float, val_fraction: float,
156
156
  With item_group=None this is a plain per-item split (frozen by
157
157
  tests/test_split_golden.py). When item_group is given (a mapping item ->
158
158
  group id), whole groups are assigned atomically so no group is ever split
159
- across train/val — the leakage protection for grouped datasets like BHAM,
160
- where one lesion contributes several images. Group-aware splitting requires
161
- class-pure groups (all of a group's items share one label); a group that
162
- spans classes raises ValueError rather than leaking silently.
159
+ across train/val — the leakage protection for grouped datasets like BHAM
160
+ (one lesion -> several images) and bret (one patient -> two eyes).
161
+
162
+ Groups MAY span classes: a bret patient can have one referable eye and one
163
+ non-referable eye, so a patient group carries both labels. Such a group is
164
+ assigned by its majority item label (ties broken toward the larger label
165
+ index, which keeps the rarer positive class populated for stratification),
166
+ and all of its items travel together to the same side. Class-pure groups
167
+ reduce exactly to the previous per-class behavior, so the BHAM golden splits
168
+ in tests/test_split_golden.py stay byte-identical.
163
169
  """
164
170
  import random
165
- from collections import defaultdict, OrderedDict
171
+ from collections import OrderedDict, Counter
166
172
  rng = random.Random(seed)
167
173
 
168
- if item_group is not None:
169
- group_labels: dict = defaultdict(set)
170
- for label in by_label:
171
- for it in by_label[label]:
172
- group_labels[item_group[it]].add(label)
173
- straddling = [g for g, labs in group_labels.items() if len(labs) > 1]
174
- if straddling:
175
- raise ValueError(
176
- f"{len(straddling)} group(s) span multiple classes; stratified "
177
- "group-aware holdout requires class-pure groups (e.g. one lesion "
178
- "-> one label). Dedupe or use a non-stratified split.")
179
-
180
- train_items: list = []
181
- val_items: list = []
182
- dist: dict = {}
174
+ if item_group is None:
175
+ # Plain per-item stratified split (frozen by tests/test_split_golden.py).
176
+ train_items: list = []
177
+ val_items: list = []
178
+ dist: dict = {}
179
+ for label in sorted(by_label):
180
+ items = list(by_label[label])
181
+ n_total = len(items)
182
+ n_tr = max(1, int(round(n_total * training_fraction)))
183
+ n_va = max(1, int(round(n_total * val_fraction)))
184
+ if n_tr + n_va > n_total:
185
+ n_va = max(1, n_total - n_tr)
186
+ rng.shuffle(items)
187
+ tr, va = items[:n_tr], items[n_tr:n_tr + n_va]
188
+ train_items.extend(tr)
189
+ val_items.extend(va)
190
+ dist[label] = {"total": n_total, "train": len(tr), "val": len(va)}
191
+ return train_items, val_items, dist
192
+
193
+ # Group-aware: assign whole groups atomically, stratified by a single
194
+ # per-group label so mixed groups never split across train/val.
195
+ grouped: dict = OrderedDict() # gid -> [items across all classes], first-seen order
196
+ item_label: dict = {}
183
197
  for label in sorted(by_label):
184
- items = list(by_label[label])
185
- n_total = len(items)
198
+ for it in by_label[label]:
199
+ grouped.setdefault(item_group[it], []).append(it)
200
+ item_label[it] = label
201
+ group_strat: dict = {}
202
+ for gid, members in grouped.items():
203
+ cnt = Counter(item_label[it] for it in members)
204
+ # majority label; tie -> larger label index (keeps rare positives populated)
205
+ group_strat[gid] = max(cnt.items(), key=lambda kv: (kv[1], kv[0]))[0]
206
+ strata: dict = OrderedDict() # strat label -> [gid, ...], first-seen order
207
+ for gid in grouped:
208
+ strata.setdefault(group_strat[gid], []).append(gid)
209
+
210
+ train_items = []
211
+ val_items = []
212
+ for label in sorted(strata):
213
+ gids = list(strata[label])
214
+ rng.shuffle(gids)
215
+ n_total = sum(len(grouped[g]) for g in gids)
186
216
  n_tr = max(1, int(round(n_total * training_fraction)))
187
217
  n_va = max(1, int(round(n_total * val_fraction)))
188
218
  if n_tr + n_va > n_total:
189
219
  n_va = max(1, n_total - n_tr)
190
-
191
- if item_group is None:
192
- rng.shuffle(items)
193
- tr, va = items[:n_tr], items[n_tr:n_tr + n_va]
194
- else:
195
- # Group items within this class, shuffle whole groups, then fill
196
- # val to its target first (so val is never empty) and train next;
197
- # atomic groups mean realized counts only approximate the targets.
198
- grouped: dict = OrderedDict()
199
- for it in items:
200
- grouped.setdefault(item_group[it], []).append(it)
201
- group_ids = list(grouped.keys())
202
- rng.shuffle(group_ids)
203
- tr, va = [], []
204
- for gid in group_ids:
205
- members = grouped[gid]
206
- if len(va) < n_va:
207
- va.extend(members)
208
- elif len(tr) < n_tr:
209
- tr.extend(members)
210
- # else: held-out remainder (matches the fractional design)
211
- if not tr and len(group_ids) >= 2:
212
- # degenerate: val absorbed everything — hand the last group to train
213
- moved = grouped[group_ids[-1]]
214
- va = [x for x in va if x not in set(moved)]
215
- tr = list(moved)
216
-
220
+ tr, va = [], []
221
+ for gid in gids:
222
+ members = grouped[gid]
223
+ if len(va) < n_va:
224
+ va.extend(members)
225
+ elif len(tr) < n_tr:
226
+ tr.extend(members)
227
+ # else: held-out remainder (matches the fractional design)
228
+ if not tr and len(gids) >= 2:
229
+ # degenerate: val absorbed everything — hand the last group to train
230
+ moved = grouped[gids[-1]]
231
+ mv = set(moved)
232
+ va = [x for x in va if x not in mv]
233
+ tr = list(moved)
217
234
  train_items.extend(tr)
218
235
  val_items.extend(va)
219
- dist[label] = {"total": n_total, "train": len(tr), "val": len(va)}
236
+
237
+ # Report distribution by TRUE per-item label (mixed groups contribute to both).
238
+ tr_set, va_set = set(train_items), set(val_items)
239
+ dist = {}
240
+ for label in sorted(by_label):
241
+ items = by_label[label]
242
+ dist[label] = {"total": len(items),
243
+ "train": sum(1 for it in items if it in tr_set),
244
+ "val": sum(1 for it in items if it in va_set)}
220
245
  return train_items, val_items, dist
221
246
 
222
247
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cvic"
7
- version = "1.2.2"
7
+ version = "1.2.4"
8
8
  description = "Local hyperparameter optimization of image classifiers using cross-validation + Ray Tune + timm"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -65,13 +65,26 @@ def test_grouped_holdout_reproducible_and_seed_sensitive():
65
65
  assert (a[0], a[1]) != (c[0], c[1]), "different seed must give a different split"
66
66
 
67
67
 
68
- def test_grouped_holdout_rejects_class_straddling_group():
69
- """A group whose items span two classes must raise, not silently leak."""
68
+ def test_grouped_holdout_keeps_class_straddling_group_whole():
69
+ """A group whose items span two classes is assigned atomically, not rejected.
70
+
71
+ (Previously this raised ValueError; bret patients can have one referable and
72
+ one non-referable eye, so a group legitimately carries both labels. The
73
+ contract is now: keep the whole group on one side — never leak it, never
74
+ drop it.)
75
+ """
70
76
  by_label = {0: ["x0", "x1"], 1: ["x2", "x3"]}
71
77
  # 'x1' and 'x2' share a group id but sit in different classes.
72
78
  item_group = {"x0": "g0", "x1": "gX", "x2": "gX", "x3": "g1"}
73
- with pytest.raises(ValueError, match="span multiple classes"):
74
- _split_by_label(by_label, 0.8, 0.2, seed=0, item_group=item_group)
79
+ for seed in range(6):
80
+ train, val, _ = _split_by_label(by_label, 0.8, 0.2, seed=seed, item_group=item_group)
81
+ # no group straddles train/val
82
+ assert {item_group[it] for it in train}.isdisjoint({item_group[it] for it in val})
83
+ # the mixed group's two items land together
84
+ assert ("x1" in train) == ("x2" in train)
85
+ assert ("x1" in val) == ("x2" in val)
86
+ # nothing lost or duplicated
87
+ assert set(train).isdisjoint(val)
75
88
 
76
89
 
77
90
  def test_ungrouped_still_matches_when_item_group_none():
@@ -0,0 +1,132 @@
1
+ """tunic group-aware hold-out must tolerate label-mixed groups (bret patients).
2
+
3
+ bret patients can have one referable eye and one non-referable eye, so a patient
4
+ group legitimately spans both classes. tunic's group-aware hold-out splitter
5
+ (_split_by_label with item_group) used to REJECT such groups with a ValueError
6
+ ("group(s) span multiple classes"); it now assigns the whole group atomically by
7
+ its majority label. These tests pin the new contract:
8
+
9
+ * a mixed group no longer raises, and is never split across train/val;
10
+ * NO group (mixed or pure) is ever split across train/val (leakage guard);
11
+ * class-pure grouped splits are unchanged (delegated to test_split_golden.py,
12
+ re-asserted here as a no-group-split invariant);
13
+ * the split is deterministic for a fixed seed.
14
+ """
15
+
16
+ from collections import defaultdict
17
+
18
+ import pytest
19
+
20
+ from cvic.tunic import _split_by_label
21
+
22
+
23
+ def _group_of(item_group, item):
24
+ return item_group[item]
25
+
26
+
27
+ def _assert_no_group_split(train, val, item_group):
28
+ """No group id may appear on both sides."""
29
+ tg = {item_group[i] for i in train}
30
+ vg = {item_group[i] for i in val}
31
+ overlap = tg & vg
32
+ assert not overlap, f"group(s) split across train/val: {sorted(overlap)}"
33
+
34
+
35
+ # --------------------------------------------------------------------------- #
36
+ # mixed groups
37
+ # --------------------------------------------------------------------------- #
38
+
39
+ def test_mixed_group_does_not_raise_and_is_not_split():
40
+ # patient p_mix has one negative (0) and one positive (1) eye
41
+ by_label = {
42
+ 0: ["p1_l", "p1_r", "p2_l", "p2_r", "p3_l", "p3_r", "pmix_neg"],
43
+ 1: ["p4_l", "p4_r", "p5_l", "p5_r", "pmix_pos"],
44
+ }
45
+ item_group = {
46
+ "p1_l": "p1", "p1_r": "p1", "p2_l": "p2", "p2_r": "p2",
47
+ "p3_l": "p3", "p3_r": "p3", "p4_l": "p4", "p4_r": "p4",
48
+ "p5_l": "p5", "p5_r": "p5",
49
+ "pmix_neg": "pmix", "pmix_pos": "pmix", # one group, both labels
50
+ }
51
+ # must not raise (the old behavior raised ValueError here)
52
+ train, val, dist = _split_by_label(by_label, 0.8, 0.2, seed=0, item_group=item_group)
53
+ _assert_no_group_split(train, val, item_group)
54
+ # both eyes of the mixed patient land together
55
+ assert ("pmix_neg" in train) == ("pmix_pos" in train)
56
+ assert ("pmix_neg" in val) == ("pmix_pos" in val)
57
+ # dist reports by true per-item label and totals are preserved
58
+ assert dist[0]["total"] == 7 and dist[1]["total"] == 5
59
+ assert dist[0]["train"] + dist[0]["val"] <= 7
60
+ # every placed item is accounted for once
61
+ assert len(set(train) & set(val)) == 0
62
+
63
+
64
+ def test_mixed_group_assigned_by_majority_then_larger_label():
65
+ # pmaj: two negatives + one positive -> majority label 0
66
+ # ptie: one negative + one positive -> tie -> larger label index (1)
67
+ by_label = {
68
+ 0: ["a", "b", "pmaj_n1", "pmaj_n2", "ptie_n"],
69
+ 1: ["c", "d", "pmaj_p1", "ptie_p"],
70
+ }
71
+ item_group = {
72
+ "a": "a", "b": "b", "c": "c", "d": "d",
73
+ "pmaj_n1": "pmaj", "pmaj_n2": "pmaj", "pmaj_p1": "pmaj",
74
+ "ptie_n": "ptie", "ptie_p": "ptie",
75
+ }
76
+ # Just assert it runs, keeps groups whole, and is leakage-free across a few seeds.
77
+ for seed in range(5):
78
+ train, val, _ = _split_by_label(by_label, 0.7, 0.3, seed=seed, item_group=item_group)
79
+ _assert_no_group_split(train, val, item_group)
80
+ assert ("pmaj_n1" in train) == ("pmaj_p1" in train) # group whole
81
+ assert ("ptie_n" in train) == ("ptie_p" in train)
82
+
83
+
84
+ def test_many_mixed_groups_never_leak():
85
+ # 30 patients, every 3rd one label-mixed; both eyes present
86
+ by_label = defaultdict(list)
87
+ item_group = {}
88
+ for p in range(30):
89
+ l, r = f"p{p}_l", f"p{p}_r"
90
+ item_group[l] = item_group[r] = f"p{p}"
91
+ if p % 3 == 0: # mixed
92
+ by_label[0].append(l)
93
+ by_label[1].append(r)
94
+ elif p % 3 == 1: # pure negative
95
+ by_label[0].extend([l, r])
96
+ else: # pure positive
97
+ by_label[1].extend([l, r])
98
+ by_label = dict(by_label)
99
+ train, val, _ = _split_by_label(by_label, 0.8, 0.2, seed=42, item_group=item_group)
100
+ _assert_no_group_split(train, val, item_group)
101
+ # nothing dropped-then-duplicated
102
+ assert len(set(train)) == len(train) and len(set(val)) == len(val)
103
+
104
+
105
+ # --------------------------------------------------------------------------- #
106
+ # pure groups: unchanged invariants
107
+ # --------------------------------------------------------------------------- #
108
+
109
+ def test_pure_groups_no_split_and_deterministic():
110
+ by_label = {
111
+ 0: [f"n{i}_{e}" for i in range(8) for e in ("l", "r")],
112
+ 1: [f"p{i}_{e}" for i in range(4) for e in ("l", "r")],
113
+ }
114
+ item_group = {}
115
+ for i in range(8):
116
+ for e in ("l", "r"):
117
+ item_group[f"n{i}_{e}"] = f"n{i}"
118
+ for i in range(4):
119
+ for e in ("l", "r"):
120
+ item_group[f"p{i}_{e}"] = f"p{i}"
121
+ a = _split_by_label(by_label, 0.8, 0.2, seed=7, item_group=item_group)
122
+ b = _split_by_label(by_label, 0.8, 0.2, seed=7, item_group=item_group)
123
+ assert a == b # deterministic
124
+ _assert_no_group_split(a[0], a[1], item_group)
125
+
126
+
127
+ def test_ungrouped_path_unaffected():
128
+ """item_group=None still does a plain per-item split (golden path)."""
129
+ by_label = {0: list(range(20)), 1: list(range(100, 110))}
130
+ train, val, dist = _split_by_label(by_label, 0.8, 0.2, seed=1, item_group=None)
131
+ assert set(train) & set(val) == set()
132
+ assert dist[0]["total"] == 20 and dist[1]["total"] == 10
@@ -322,7 +322,7 @@ nvtx = [
322
322
 
323
323
  [[package]]
324
324
  name = "cvic"
325
- version = "1.2.2"
325
+ version = "1.2.4"
326
326
  source = { editable = "." }
327
327
  dependencies = [
328
328
  { name = "matplotlib" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes