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.
- {cvic-1.2.2 → cvic-1.2.4}/PKG-INFO +1 -1
- {cvic-1.2.2 → cvic-1.2.4}/cvic/tunic.py +75 -50
- {cvic-1.2.2 → cvic-1.2.4}/pyproject.toml +1 -1
- {cvic-1.2.2 → cvic-1.2.4}/tests/test_holdout_grouping.py +17 -4
- cvic-1.2.4/tests/test_holdout_mixed_groups.py +132 -0
- {cvic-1.2.2 → cvic-1.2.4}/uv.lock +1 -1
- {cvic-1.2.2 → cvic-1.2.4}/.github/workflows/test.yml +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/.gitignore +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/.python-version +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/LICENSE +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/README.md +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/cvic/__init__.py +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/cvic/common_cvic.py +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/cvic/cvic.py +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/requirements.txt +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/tests/test_common_cvic.py +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/tests/test_cv_folds.py +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/tests/test_cvic.py +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/tests/test_cvic_group_map.py +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/tests/test_cvic_pooling.py +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/tests/test_group_map_param_passing.py +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/tests/test_group_map_wiring.py +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/tests/test_split_golden.py +0 -0
- {cvic-1.2.2 → cvic-1.2.4}/tests/test_tunic.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cvic
|
|
3
|
-
Version: 1.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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
171
|
+
from collections import OrderedDict, Counter
|
|
166
172
|
rng = random.Random(seed)
|
|
167
173
|
|
|
168
|
-
if item_group is
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
tr
|
|
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
|
-
|
|
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.
|
|
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
|
|
69
|
-
"""A group whose items span two classes
|
|
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
|
-
|
|
74
|
-
_split_by_label(by_label, 0.8, 0.2, seed=
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|