Spaces:
Running on Zero
Running on Zero
File size: 14,186 Bytes
b379113 | 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 | """Leak-free cross-validation splits.
The failure this module exists to prevent
-----------------------------------------
Fundus datasets contain both eyes of the same patient, and the two eyes of one
diabetic are strongly correlated in DR severity. If one eye lands in train and
the other in test, the model has effectively seen the test set. Every reported
metric inflates, the loss curves look perfect, and nothing warns you.
The complication specific to APTOS
----------------------------------
APTOS-2019's `train.csv` carries only `id_code` and `diagnosis`. There is **no
patient or eye identifier**, so a true patient-level split is impossible on that
dataset as distributed. Silently falling back to an image-level split would be
exactly the failure described above, so this module:
1. refuses to guess -- `stratified_group_split` requires an explicit decision;
2. offers `perceptual_hash_groups` as a documented mitigation: images that are
near-identical (plausibly the same eye or patient) are grouped so they
cannot straddle folds;
3. records which strategy was used, so the caveat travels with the results.
Messidor-2 *does* encode exam pairing in its filenames, so real grouping is
available there and should be used.
"""
from __future__ import annotations
import warnings
from collections import defaultdict
import numpy as np
__all__ = [
"SplitStrategy",
"dhash",
"structural_hash",
"perceptual_hash_groups",
"validate_grouping",
"stratified_group_split",
"assert_no_group_leakage",
]
class SplitStrategy:
"""How grouping was determined. Travels with results as a provenance record."""
TRUE_GROUPS = "true_groups" # real patient/eye ids -- trustworthy
PERCEPTUAL = "perceptual_hash" # near-duplicate grouping -- mitigation only
IMAGE_LEVEL = "image_level" # no grouping -- leakage possible, must be declared
def dhash(image: np.ndarray, hash_size: int = 8) -> int:
"""Difference hash: a 64-bit perceptual fingerprint.
Compares each pixel with its right-hand neighbour on a downscaled grayscale
image, so it is robust to resolution, JPEG artefacts and mild brightness
shifts, but sensitive to actual content. Two photographs of the same eye
typically differ by only a few bits.
Implemented here rather than pulled from `imagehash` to avoid a dependency
for ~10 lines, and so the exact behaviour is pinned by our own tests.
"""
import cv2
if image.ndim == 3:
image = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY)
resized = cv2.resize(image, (hash_size + 1, hash_size), interpolation=cv2.INTER_AREA)
diff = resized[:, 1:] > resized[:, :-1]
bits = 0
for bit in diff.flatten():
bits = (bits << 1) | int(bit)
return bits
def structural_hash(image: np.ndarray, size: int = 256, hash_size: int = 16) -> int:
"""Perceptual hash of an image's *coarse structure*, for grouping.
Deliberately computed on a plain circle-crop + resize, NOT on the
Ben Graham-preprocessed image used for training.
Ben Graham preprocessing subtracts the local average colour, which removes
exactly the low-frequency content dHash depends on. Measured on a fundus and
a degraded copy of itself: hashing the Ben Graham output put them 8 bits
apart (above the grouping threshold, so the duplicate was missed), while
hashing the structural image put them 0 bits apart -- with unrelated images
still 14 bits away.
The identity of an eye lives in its vessel and optic-disc layout. That is
what this hashes.
Resolution matters more than it looks. At 8x8 (64 bits) on 128 px, measured
across APTOS the median inter-image distance was 18 bits with ~670 pairs
falling within 5 bits -- most of them false matches, because 64 bits cannot
separate two fundus photographs that are both "a bright disc on black".
At 16x16 (256 bits) on 256 px the distribution is cleanly bimodal: 148 pairs
at 0-4 bits and then NOTHING until 39 bits. Those false matches are what
percolated the grouping into a single giant component.
"""
import cv2
from drdetect.enhance.preprocessing import circle_crop
cropped = circle_crop(image)
resized = cv2.resize(cropped, (size, size), interpolation=cv2.INTER_AREA)
return dhash(resized, hash_size=hash_size)
def _hamming(a: int, b: int) -> int:
return bin(a ^ b).count("1")
def perceptual_hash_groups(
image_ids: list[str],
hashes: list[int],
max_distance: int = 6,
*,
max_group_size: int | None = None,
) -> dict[str, str]:
"""Group near-duplicate images via union-find over Hamming distance.
Returns {image_id: group_id}. Images within `max_distance` bits are treated
as the same group (transitively).
Percolation, and why `max_group_size` exists
--------------------------------------------
Union-find takes the TRANSITIVE closure: a~b and b~c puts a, b and c in one
group even if a and c are far apart. Over a large set, a small number of
spurious edges is enough to chain almost everything together -- a random
graph needs only ~N/2 edges to form a giant connected component.
This is not hypothetical. On APTOS (3,662 images) a 64-bit hash produced
~670 near-matches, and the transitive closure merged 3,138 images into a
single "group" which then became an entire validation fold. The split was
train=524 / val=3138 and silently reported as `true_groups`.
Two defences: a hash resolution that does not manufacture false edges (see
`structural_hash`), and this hard cap on group size. `max_group_size`
defaults to max(25, 1% of the dataset) -- a "patient" larger than that is a
hash failure, not a patient, so the merge is refused.
Choosing `max_distance`
-----------------------
Measured on APTOS with `structural_hash` over all 6.7M pairs, the cumulative
count is flat across thresholds 2-6 (148 pairs at every value) and stays
near-flat to 8 (150). It then climbs -- 207 at 10, 391 at 12 -- and chaining
appears: threshold 10 produced a 31-image group whose members were up to 33
bits apart. The default of 6 sits mid-plateau, so the result is insensitive
to the exact value.
Note the threshold must be measured for the hash ACTUALLY used. An earlier
value of 10 was taken from a distribution measured on Ben Graham-preprocessed
images and then applied to `structural_hash`, which runs on raw
circle-cropped images. Different transform, different distances.
This remains a *mitigation*, not a substitute for patient IDs: it catches
repeated or near-identical captures, but two genuinely different photographs
of one patient's two eyes will not be grouped. Declare the limitation when
reporting.
"""
if len(image_ids) != len(hashes):
raise ValueError("image_ids and hashes must be the same length")
n = len(image_ids)
if max_group_size is None:
max_group_size = max(25, n // 100)
parent = list(range(n))
size = [1] * n
def find(i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
refused = 0
def union(i: int, j: int) -> None:
nonlocal refused
ri, rj = find(i), find(j)
if ri == rj:
return
if size[ri] + size[rj] > max_group_size:
# Refusing the merge is the safe failure: two smaller groups may
# leak, but one giant group destroys the split entirely.
refused += 1
return
lo, hi = (ri, rj) if ri < rj else (rj, ri)
parent[hi] = lo
size[lo] += size[hi]
# O(n^2). Fine for APTOS (3,662 -> ~6.7M comparisons, seconds). For EyePACS
# scale this would need LSH bucketing on hash prefixes.
for i in range(n):
hi_hash = hashes[i]
for j in range(i + 1, n):
if (hi_hash ^ hashes[j]).bit_count() <= max_distance:
union(i, j)
if refused:
warnings.warn(
f"{refused} merge(s) refused because they would exceed max_group_size="
f"{max_group_size}. This usually means the hash is producing false "
f"matches; check structural_hash resolution and max_distance.",
UserWarning,
stacklevel=2,
)
roots = defaultdict(list)
for idx, image_id in enumerate(image_ids):
roots[find(idx)].append(image_id)
# Name each group after its lexicographically smallest member, NOT the
# union-find root index. The root index depends on the order the inputs
# arrived in, so identical data grouped twice produced identical partitions
# under different names -- and StratifiedGroupKFold assigns folds by group
# VALUE, so the two runs got different splits. Measured: the 512 px and
# 768 px manifests had the same 3,523 groups but shared only 139 of 733
# validation images, making the two runs non-comparable.
#
# Naming by member makes the mapping a pure function of the partition.
mapping: dict[str, str] = {}
for members in roots.values():
gid = f"g_{min(members)}"
for m in members:
mapping[m] = gid
return mapping
def validate_grouping(
groups: list[str],
*,
max_group_fraction: float = 0.05,
min_group_fraction: float = 0.5,
) -> dict:
"""Reject degenerate groupings before they silently destroy a split.
Raises if any single group holds more than `max_group_fraction` of the data,
or if the number of distinct groups falls below `min_group_fraction` of the
number of images.
This exists because the failure it catches is invisible downstream: a
grouping that collapsed 86% of APTOS into one group still reported strategy
`true_groups`, still passed the no-leakage assertion (trivially -- one group
cannot straddle folds), and produced a train=524 / val=3138 split that would
have trained for hours and produced a meaningless number.
"""
from collections import Counter
n = len(groups)
counts = Counter(groups)
largest_id, largest = counts.most_common(1)[0]
stats = {
"n_images": n,
"n_groups": len(counts),
"largest_group": largest,
"largest_group_id": largest_id,
"merged": n - len(counts),
}
if largest > max_group_fraction * n:
raise ValueError(
f"Degenerate grouping: group {largest_id!r} holds {largest}/{n} images "
f"({100 * largest / n:.1f}%), above the {100 * max_group_fraction:.0f}% limit.\n"
f"A 'patient' that large is a hash collapse, not a patient. Re-run "
f"grouping with a higher-resolution hash or a tighter max_distance."
)
if len(counts) < min_group_fraction * n:
raise ValueError(
f"Degenerate grouping: {len(counts)} groups for {n} images "
f"(< {100 * min_group_fraction:.0f}%). The hash is over-merging."
)
return stats
def stratified_group_split(
labels: list[int],
groups: list[str] | None,
*,
n_splits: int = 5,
seed: int = 42,
allow_ungrouped: bool = False,
) -> tuple[list[np.ndarray], str]:
"""Stratified k-fold that keeps each group whole.
Args:
labels: class label per sample, used for stratification.
groups: grouping key per sample. `None` or all-empty means no grouping
information exists.
allow_ungrouped: must be set explicitly to proceed without groups. This
is a guard rail: falling back silently is the bug this module exists
to prevent.
Returns:
(fold_indices, strategy) where fold_indices[k] holds the *test* indices
of fold k, and strategy is a `SplitStrategy` constant to record.
Raises:
ValueError: if grouping is absent and `allow_ungrouped` is False.
"""
from sklearn.model_selection import StratifiedGroupKFold, StratifiedKFold
labels_arr = np.asarray(labels)
has_groups = groups is not None and any(g for g in groups)
if not has_groups:
if not allow_ungrouped:
raise ValueError(
"No grouping information supplied. A plain image-level split can leak "
"the same patient across folds and silently inflate every metric.\n"
"Either provide `groups` (from patient ids, or from "
"perceptual_hash_groups()), or pass allow_ungrouped=True to accept "
"the risk explicitly and declare it when reporting results."
)
warnings.warn(
"Splitting at IMAGE level with no grouping. If this dataset contains "
"multiple images per patient, results will be optimistically biased. "
"Record SplitStrategy.IMAGE_LEVEL alongside any metric derived from it.",
UserWarning,
stacklevel=2,
)
splitter = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed)
folds = [test for _, test in splitter.split(np.zeros(len(labels_arr)), labels_arr)]
return folds, SplitStrategy.IMAGE_LEVEL
groups_arr = np.asarray(groups)
splitter = StratifiedGroupKFold(n_splits=n_splits, shuffle=True, random_state=seed)
folds = [test for _, test in splitter.split(np.zeros(len(labels_arr)), labels_arr, groups_arr)]
return folds, SplitStrategy.TRUE_GROUPS
def assert_no_group_leakage(folds: list[np.ndarray], groups: list[str]) -> None:
"""Raise if any group appears in more than one fold.
Call this after splitting, every time. It is cheap, and it converts the most
expensive silent bug in this literature into a loud, immediate failure.
"""
seen: dict[str, int] = {}
for fold_idx, indices in enumerate(folds):
for i in indices:
gid = groups[i]
if not gid:
continue
prev = seen.setdefault(gid, fold_idx)
if prev != fold_idx:
raise AssertionError(
f"Group leakage: group {gid!r} appears in folds {prev} and {fold_idx}."
)
|