hssling commited on
Commit
5baabc5
·
verified ·
1 Parent(s): 78faeac

Sync training package

Browse files
training/__init__.py ADDED
File without changes
training/config.yaml ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # training/config.yaml
2
+ # All hyperparameters and paths. Override via CLI args in train.py.
3
+
4
+ data:
5
+ hf_dataset_repo: "hssling/anemia-conjunctiva-nailbed"
6
+ image_size: 380 # EfficientNet-B4 canonical input
7
+ batch_size: 32
8
+ num_workers: 4
9
+
10
+ model:
11
+ architectures:
12
+ - name: efficientnet_b4
13
+ pretrained: true
14
+ unfreeze_last_n_blocks: 3
15
+ - name: efficientnetv2_s
16
+ pretrained: true
17
+ unfreeze_last_n_blocks: 3
18
+ - name: convnext_tiny
19
+ pretrained: true
20
+ unfreeze_last_n_blocks: 3
21
+ dropout_rate: 0.3
22
+ mc_dropout_samples: 30 # for uncertainty (CI95) estimation
23
+
24
+ training:
25
+ phase1_epochs: 10
26
+ phase2_epochs: 30
27
+ phase1_lr: 1.0e-3
28
+ phase2_lr: 1.0e-5
29
+ weight_decay: 1.0e-4
30
+ early_stopping_patience: 5
31
+ loss_regression_weight: 0.7
32
+ loss_classification_weight: 0.3
33
+ random_seed: 42
34
+ n_folds: 5
35
+
36
+ classes:
37
+ - normal
38
+ - mild
39
+ - moderate
40
+ - severe
41
+
42
+ output:
43
+ model_dir: "outputs/models"
44
+ metrics_dir: "outputs/metrics"
45
+ figures_dir: "outputs/figures"
46
+
47
+ wandb:
48
+ project: "anemiascan"
49
+ entity: null # set to your W&B username if needed
training/cross_validation.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # training/cross_validation.py
2
+ """
3
+ 5-fold stratified cross-validation runner.
4
+
5
+ CV is used for metric estimation only.
6
+ Final model is retrained on full train+val after CV.
7
+
8
+ Usage:
9
+ python training/cross_validation.py \
10
+ --model efficientnet_b4 \
11
+ --config training/config.yaml \
12
+ --output-dir outputs/cv/
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import logging
18
+ import pathlib
19
+
20
+ import numpy as np
21
+ from sklearn.model_selection import StratifiedKFold
22
+
23
+ from training.train import load_config, train_model
24
+
25
+ log = logging.getLogger(__name__)
26
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
27
+
28
+
29
+ def run_cross_validation(
30
+ rows: list,
31
+ model_name: str,
32
+ config: dict,
33
+ output_dir: pathlib.Path,
34
+ ) -> dict:
35
+ """
36
+ Run 5-fold stratified CV. Returns dict with mean +/- std of each metric.
37
+ """
38
+ n_folds = config["training"]["n_folds"]
39
+ fold_metrics = []
40
+
41
+ strat_labels = [r["anemia_class"] for r in rows]
42
+ skf = StratifiedKFold(
43
+ n_splits=n_folds, shuffle=True, random_state=config["training"]["random_seed"]
44
+ )
45
+
46
+ for fold, (train_idx, val_idx) in enumerate(skf.split(rows, strat_labels)):
47
+ log.info(f"=== Fold {fold + 1}/{n_folds} ===")
48
+ train_rows = [rows[i] for i in train_idx]
49
+ val_rows = [rows[i] for i in val_idx]
50
+ fold_out = output_dir / f"fold_{fold}"
51
+ fold_out.mkdir(parents=True, exist_ok=True)
52
+ metrics = train_model(
53
+ model_name=model_name,
54
+ train_rows=train_rows,
55
+ val_rows=val_rows,
56
+ config=config,
57
+ output_dir=fold_out,
58
+ fold=fold,
59
+ run_name=f"{model_name}_cv_fold{fold}",
60
+ )
61
+ fold_metrics.append(metrics)
62
+
63
+ all_keys = fold_metrics[0].keys()
64
+ summary = {}
65
+ for key in all_keys:
66
+ vals = [m[key] for m in fold_metrics if isinstance(m.get(key), int | float)]
67
+ if vals:
68
+ summary[f"{key}_mean"] = float(np.mean(vals))
69
+ summary[f"{key}_std"] = float(np.std(vals))
70
+
71
+ summary["n_folds"] = n_folds
72
+ summary["model"] = model_name
73
+ out_path = output_dir / f"{model_name}_cv_summary.json"
74
+ with open(out_path, "w") as f:
75
+ json.dump(summary, f, indent=2)
76
+ log.info(
77
+ f"CV summary: MAE={summary.get('mae_mean', '?'):.3f} +/- {summary.get('mae_std', '?'):.3f}"
78
+ )
79
+ return summary
80
+
81
+
82
+ def main():
83
+ parser = argparse.ArgumentParser()
84
+ parser.add_argument("--model", required=True)
85
+ parser.add_argument("--config", default="training/config.yaml", type=pathlib.Path)
86
+ parser.add_argument("--output-dir", default="outputs/cv", type=pathlib.Path)
87
+ args = parser.parse_args()
88
+
89
+ load_config(args.config)
90
+ log.info(f"Cross-validation for {args.model}")
91
+ log.info("Load your dataset rows and call run_cross_validation(rows, ...)")
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
training/evaluation/__init__.py ADDED
File without changes
training/evaluation/metrics.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # training/evaluation/metrics.py
2
+ """Evaluation metrics for hemoglobin regression and anemia classification."""
3
+
4
+ import numpy as np
5
+ from scipy import stats
6
+ from sklearn.metrics import confusion_matrix, f1_score, roc_auc_score
7
+
8
+
9
+ def compute_regression_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
10
+ """MAE, RMSE, Pearson r for Hb regression."""
11
+ mae = float(np.mean(np.abs(y_true - y_pred)))
12
+ rmse = float(np.sqrt(np.mean((y_true - y_pred) ** 2)))
13
+ r, p_val = stats.pearsonr(y_true, y_pred)
14
+ return {"mae": mae, "rmse": rmse, "pearson_r": float(r), "pearson_p": float(p_val)}
15
+
16
+
17
+ def compute_classification_metrics(y_true: np.ndarray, y_pred_proba: np.ndarray) -> dict:
18
+ """AUC, F1, sensitivity, specificity, confusion matrix for 4-class anemia."""
19
+ y_pred = np.argmax(y_pred_proba, axis=1)
20
+ cm = confusion_matrix(y_true, y_pred, labels=[0, 1, 2, 3])
21
+
22
+ per_class_sens = {}
23
+ per_class_spec = {}
24
+ for cls in range(4):
25
+ tp = cm[cls, cls]
26
+ fn = cm[cls, :].sum() - tp
27
+ fp = cm[:, cls].sum() - tp
28
+ tn = cm.sum() - tp - fn - fp
29
+ per_class_sens[cls] = tp / (tp + fn) if (tp + fn) > 0 else 0.0
30
+ per_class_spec[cls] = tn / (tn + fp) if (tn + fp) > 0 else 0.0
31
+
32
+ try:
33
+ auc_macro = float(roc_auc_score(y_true, y_pred_proba, multi_class="ovr", average="macro"))
34
+ except ValueError:
35
+ auc_macro = float("nan")
36
+
37
+ return {
38
+ "auc_macro": auc_macro,
39
+ "f1_macro": float(f1_score(y_true, y_pred, average="macro", zero_division=0)),
40
+ "sensitivity_per_class": per_class_sens,
41
+ "specificity_per_class": per_class_spec,
42
+ "confusion_matrix": cm,
43
+ }
44
+
45
+
46
+ def bland_altman_stats(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
47
+ """Bland-Altman agreement statistics."""
48
+ diff = y_true - y_pred
49
+ mean_diff = float(np.mean(diff))
50
+ std_diff = float(np.std(diff, ddof=1))
51
+ return {
52
+ "mean_diff": mean_diff,
53
+ "std_diff": std_diff,
54
+ "loa_upper": mean_diff + 1.96 * std_diff,
55
+ "loa_lower": mean_diff - 1.96 * std_diff,
56
+ }
training/models/__init__.py ADDED
File without changes
training/models/convnext_tiny.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # training/models/convnext_tiny.py
2
+ """ConvNeXt-Tiny dual-head model."""
3
+
4
+ import timm
5
+ import torch
6
+ import torch.nn as nn
7
+
8
+
9
+ class AnemiaModel(nn.Module):
10
+ def __init__(self, num_classes: int = 4, dropout_rate: float = 0.3, pretrained: bool = True):
11
+ super().__init__()
12
+ self.backbone = timm.create_model(
13
+ "convnext_tiny", pretrained=pretrained, num_classes=0, global_pool="avg"
14
+ )
15
+ feature_dim = self.backbone.num_features
16
+ self.regression_head = nn.Sequential(
17
+ nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
18
+ )
19
+ self.classification_head = nn.Sequential(
20
+ nn.Linear(feature_dim, 256),
21
+ nn.ReLU(),
22
+ nn.Dropout(dropout_rate),
23
+ nn.Linear(256, num_classes),
24
+ )
25
+
26
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
27
+ f = self.backbone(x)
28
+ return self.regression_head(f), self.classification_head(f)
29
+
30
+ def freeze_backbone(self):
31
+ for p in self.backbone.parameters():
32
+ p.requires_grad = False
33
+
34
+ def unfreeze_last_n_blocks(self, n: int = 3):
35
+ stages = list(self.backbone.stages)
36
+ for stage in stages[-n:]:
37
+ for p in stage.parameters():
38
+ p.requires_grad = True
training/models/efficientnet_b4.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # training/models/efficientnet_b4.py
2
+ """EfficientNet-B4 dual-head model for hemoglobin regression + anemia classification."""
3
+
4
+ import timm
5
+ import torch
6
+ import torch.nn as nn
7
+
8
+
9
+ class AnemiaModel(nn.Module):
10
+ """
11
+ EfficientNet-B4 backbone with dual prediction heads:
12
+ - Regression head: predicts Hb (g/dL)
13
+ - Classification head: predicts 4-class anemia severity
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ num_classes: int = 4,
19
+ dropout_rate: float = 0.3,
20
+ pretrained: bool = True,
21
+ ):
22
+ super().__init__()
23
+ self.backbone = timm.create_model(
24
+ "efficientnet_b4",
25
+ pretrained=pretrained,
26
+ num_classes=0, # remove classifier head
27
+ global_pool="avg",
28
+ )
29
+ feature_dim = self.backbone.num_features
30
+
31
+ self.regression_head = nn.Sequential(
32
+ nn.Linear(feature_dim, 256),
33
+ nn.ReLU(),
34
+ nn.Dropout(dropout_rate),
35
+ nn.Linear(256, 1),
36
+ )
37
+ self.classification_head = nn.Sequential(
38
+ nn.Linear(feature_dim, 256),
39
+ nn.ReLU(),
40
+ nn.Dropout(dropout_rate),
41
+ nn.Linear(256, num_classes),
42
+ )
43
+
44
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
45
+ features = self.backbone(x)
46
+ hb_pred = self.regression_head(features)
47
+ class_logits = self.classification_head(features)
48
+ return hb_pred, class_logits
49
+
50
+ def freeze_backbone(self):
51
+ for param in self.backbone.parameters():
52
+ param.requires_grad = False
53
+
54
+ def unfreeze_last_n_blocks(self, n: int = 3):
55
+ """Unfreeze last n blocks of the backbone for fine-tuning."""
56
+ blocks = list(self.backbone.blocks)
57
+ for block in blocks[-n:]:
58
+ for param in block.parameters():
59
+ param.requires_grad = True
60
+ # Always unfreeze the final conv + bn
61
+ for param in self.backbone.conv_head.parameters():
62
+ param.requires_grad = True
63
+ for param in self.backbone.bn2.parameters():
64
+ param.requires_grad = True
training/models/efficientnetv2_s.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # training/models/efficientnetv2_s.py
2
+ """EfficientNetV2-S dual-head model."""
3
+
4
+ import timm
5
+ import torch
6
+ import torch.nn as nn
7
+
8
+
9
+ class AnemiaModel(nn.Module):
10
+ def __init__(self, num_classes: int = 4, dropout_rate: float = 0.3, pretrained: bool = True):
11
+ super().__init__()
12
+ self.backbone = timm.create_model(
13
+ "tf_efficientnetv2_s", pretrained=pretrained, num_classes=0, global_pool="avg"
14
+ )
15
+ feature_dim = self.backbone.num_features
16
+ self.regression_head = nn.Sequential(
17
+ nn.Linear(feature_dim, 256), nn.ReLU(), nn.Dropout(dropout_rate), nn.Linear(256, 1)
18
+ )
19
+ self.classification_head = nn.Sequential(
20
+ nn.Linear(feature_dim, 256),
21
+ nn.ReLU(),
22
+ nn.Dropout(dropout_rate),
23
+ nn.Linear(256, num_classes),
24
+ )
25
+
26
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
27
+ f = self.backbone(x)
28
+ return self.regression_head(f), self.classification_head(f)
29
+
30
+ def freeze_backbone(self):
31
+ for p in self.backbone.parameters():
32
+ p.requires_grad = False
33
+
34
+ def unfreeze_last_n_blocks(self, n: int = 3):
35
+ blocks = list(self.backbone.blocks)
36
+ for block in blocks[-n:]:
37
+ for p in block.parameters():
38
+ p.requires_grad = True
39
+ # Also unfreeze final conv + bn for consistent gradient flow with B4
40
+ if hasattr(self.backbone, "conv_head"):
41
+ for p in self.backbone.conv_head.parameters():
42
+ p.requires_grad = True
43
+ if hasattr(self.backbone, "bn2"):
44
+ for p in self.backbone.bn2.parameters():
45
+ p.requires_grad = True
training/models/ensemble.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # training/models/ensemble.py
2
+ """
3
+ Late-fusion dual-site ensemble.
4
+
5
+ Loads a conjunctiva model and a nail-bed model.
6
+ Combines predictions with learned weights (optimised on val set).
7
+ Falls back gracefully if only one site image is provided.
8
+ """
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+ from safetensors.torch import load_file
13
+
14
+ from training.models.efficientnet_b4 import AnemiaModel
15
+
16
+
17
+ class AnemiaEnsemble(nn.Module):
18
+ def __init__(
19
+ self,
20
+ conj_ckpt: str,
21
+ nail_ckpt: str,
22
+ w_conj: float = 0.5,
23
+ w_nail: float = 0.5,
24
+ ):
25
+ super().__init__()
26
+ self.conj_model = AnemiaModel(pretrained=False)
27
+ self.nail_model = AnemiaModel(pretrained=False)
28
+ self.conj_model.load_state_dict(load_file(conj_ckpt))
29
+ self.nail_model.load_state_dict(load_file(nail_ckpt))
30
+ self.w_conj = w_conj
31
+ self.w_nail = w_nail
32
+
33
+ def forward(
34
+ self,
35
+ conj_img: torch.Tensor | None = None,
36
+ nail_img: torch.Tensor | None = None,
37
+ ) -> tuple[torch.Tensor, torch.Tensor]:
38
+ if conj_img is not None and nail_img is not None:
39
+ hb_c, cls_c = self.conj_model(conj_img)
40
+ hb_n, cls_n = self.nail_model(nail_img)
41
+ hb = self.w_conj * hb_c + self.w_nail * hb_n
42
+ cls = self.w_conj * cls_c + self.w_nail * cls_n
43
+ elif conj_img is not None:
44
+ hb, cls = self.conj_model(conj_img)
45
+ elif nail_img is not None:
46
+ hb, cls = self.nail_model(nail_img)
47
+ else:
48
+ raise ValueError("At least one image (conjunctiva or nail-bed) must be provided")
49
+ return hb, cls
50
+
51
+ @classmethod
52
+ def find_best_weights(
53
+ cls,
54
+ conj_ckpt: str,
55
+ nail_ckpt: str,
56
+ val_rows_conj: list,
57
+ val_rows_nail: list,
58
+ config: dict,
59
+ ) -> tuple[float, float]:
60
+ """Grid search over ensemble weights on validation set. Returns (w_conj, w_nail).
61
+
62
+ IMPORTANT: val_rows_conj and val_rows_nail must be from the same patients
63
+ in the same order. The ensemble MAE is evaluated against conjunctiva ground-truth
64
+ (trues_c). Only valid when both sets cover the same patient population.
65
+ """
66
+ if len(val_rows_conj) != len(val_rows_nail):
67
+ raise ValueError(
68
+ f"val_rows_conj ({len(val_rows_conj)}) and val_rows_nail "
69
+ f"({len(val_rows_nail)}) must have the same length for ensemble "
70
+ "weight grid search. Ensure both cover the same patients."
71
+ )
72
+ import numpy as np
73
+ from torch.utils.data import DataLoader
74
+
75
+ from training.evaluation.metrics import compute_regression_metrics
76
+ from training.utils.dataset import AnemiaDataset
77
+
78
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
79
+ img_size = config["data"]["image_size"]
80
+
81
+ conj_model = AnemiaModel(pretrained=False).to(device)
82
+ nail_model = AnemiaModel(pretrained=False).to(device)
83
+ conj_model.load_state_dict(load_file(conj_ckpt))
84
+ nail_model.load_state_dict(load_file(nail_ckpt))
85
+ conj_model.eval()
86
+ nail_model.eval()
87
+
88
+ def get_preds(model, rows):
89
+ ds = AnemiaDataset(rows, image_size=img_size, augment=False)
90
+ loader = DataLoader(ds, batch_size=32)
91
+ preds, trues = [], []
92
+ with torch.no_grad():
93
+ for imgs, hb, _ in loader:
94
+ hb_pred, _ = model(imgs.to(device))
95
+ preds.extend(hb_pred.squeeze(1).cpu().numpy())
96
+ trues.extend(hb.numpy())
97
+ return np.array(preds), np.array(trues)
98
+
99
+ preds_c, trues_c = get_preds(conj_model, val_rows_conj)
100
+ preds_n, _ = get_preds(nail_model, val_rows_nail)
101
+
102
+ best_mae, best_wc = float("inf"), 0.5
103
+ for wc in np.arange(0.0, 1.05, 0.05):
104
+ wn = 1.0 - wc
105
+ ensemble_preds = wc * preds_c + wn * preds_n
106
+ mae = compute_regression_metrics(trues_c, ensemble_preds)["mae"]
107
+ if mae < best_mae:
108
+ best_mae, best_wc = mae, wc
109
+
110
+ return float(best_wc), float(1.0 - best_wc)
training/push_model_to_hf.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # training/push_model_to_hf.py
2
+ """Push trained model weights and metrics to HuggingFace Hub."""
3
+
4
+ import json
5
+ import logging
6
+ import pathlib
7
+ import shutil
8
+ import tempfile
9
+
10
+ from huggingface_hub import HfApi
11
+
12
+ log = logging.getLogger(__name__)
13
+ api = HfApi()
14
+
15
+
16
+ def push_model(
17
+ ckpt_path: str,
18
+ repo_id: str,
19
+ metrics: dict,
20
+ model_name: str,
21
+ site: str,
22
+ config: dict,
23
+ version: str = "v1.0.0",
24
+ ):
25
+ """Push a single model checkpoint + metrics to HF Hub."""
26
+ with tempfile.TemporaryDirectory() as tmpdir:
27
+ tmp = pathlib.Path(tmpdir)
28
+ shutil.copy(ckpt_path, tmp / "model.safetensors")
29
+ (tmp / "metrics.json").write_text(json.dumps(metrics, indent=2))
30
+ card = f"""---
31
+ language: en
32
+ license: cc-by-nc-4.0
33
+ tags:
34
+ - medical-imaging
35
+ - anemia
36
+ - hemoglobin-estimation
37
+ - image-classification
38
+ pipeline_tag: image-classification
39
+ ---
40
+
41
+ # AnemiaScan -- {model_name} ({site})
42
+
43
+ **Task:** Non-invasive hemoglobin estimation + anemia severity classification from {site} images.
44
+
45
+ **Architecture:** {model_name} (ImageNet pretrained, fine-tuned)
46
+
47
+ **Input:** 380x380 RGB image of the palpebral {site}
48
+
49
+ **Outputs:**
50
+ - `hb_estimate` (float, g/dL)
51
+ - `classification` (str: normal / mild / moderate / severe)
52
+
53
+ ## Performance (5-fold CV on public datasets)
54
+
55
+ | Metric | Mean +/- Std |
56
+ |--------|-----------|
57
+ | MAE (g/dL) | {metrics.get("mae_mean", "TBD")} |
58
+ | Pearson r | {metrics.get("pearson_r_mean", "TBD")} |
59
+ | AUC (macro) | {metrics.get("auc_mean", "TBD")} |
60
+
61
+ ## Disclaimer
62
+
63
+ **Research tool only. Not a certified diagnostic device. All results require clinical confirmation.**
64
+ """
65
+ (tmp / "README.md").write_text(card)
66
+ api.upload_folder(
67
+ folder_path=str(tmp),
68
+ repo_id=repo_id,
69
+ repo_type="model",
70
+ commit_message=f"Add {model_name} {site} weights {version}",
71
+ )
72
+ log.info(f"Pushed to https://huggingface.co/{repo_id}")
73
+
74
+
75
+ def push_all_models(
76
+ conj_ckpt: str,
77
+ nail_ckpt: str,
78
+ cv_summary_conj: dict,
79
+ cv_summary_nail: dict,
80
+ w_conj: float,
81
+ w_nail: float,
82
+ config: dict,
83
+ ):
84
+ push_model(
85
+ conj_ckpt,
86
+ "hssling/anemia-efficientnet-b4-conjunctiva",
87
+ cv_summary_conj,
88
+ "efficientnet_b4",
89
+ "conjunctiva",
90
+ config,
91
+ )
92
+ push_model(
93
+ nail_ckpt,
94
+ "hssling/anemia-efficientnet-b4-nailbed",
95
+ cv_summary_nail,
96
+ "efficientnet_b4",
97
+ "nailbed",
98
+ config,
99
+ )
100
+ ensemble_meta = {
101
+ "conj_model": "hssling/anemia-efficientnet-b4-conjunctiva",
102
+ "nail_model": "hssling/anemia-efficientnet-b4-nailbed",
103
+ "w_conj": w_conj,
104
+ "w_nail": w_nail,
105
+ "mae_mean": w_conj * cv_summary_conj.get("mae_mean", 0)
106
+ + w_nail * cv_summary_nail.get("mae_mean", 0),
107
+ }
108
+ api.upload_file(
109
+ path_or_fileobj=json.dumps(ensemble_meta, indent=2).encode(),
110
+ path_in_repo="ensemble_config.json",
111
+ repo_id="hssling/anemia-ensemble",
112
+ repo_type="model",
113
+ commit_message="Add ensemble configuration",
114
+ )
115
+ log.info("Ensemble config pushed")
training/train.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # training/train.py
2
+ """
3
+ Core training loop: two-phase training (head warmup -> backbone fine-tune).
4
+
5
+ Usage:
6
+ python training/train.py \
7
+ --model efficientnet_b4 \
8
+ --site conjunctiva \
9
+ --config training/config.yaml \
10
+ --output-dir outputs/
11
+ """
12
+
13
+ import argparse
14
+ import importlib
15
+ import json
16
+ import logging
17
+ import pathlib
18
+
19
+ import numpy as np
20
+ import torch
21
+ import torch.nn as nn
22
+ import wandb
23
+ import yaml
24
+ from torch.utils.data import DataLoader
25
+
26
+ from training.evaluation.metrics import (
27
+ compute_classification_metrics,
28
+ compute_regression_metrics,
29
+ )
30
+ from training.utils.dataset import AnemiaDataset
31
+
32
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ def load_config(path: pathlib.Path) -> dict:
37
+ with open(path) as f:
38
+ return yaml.safe_load(f)
39
+
40
+
41
+ def get_model(model_name: str, config: dict) -> nn.Module:
42
+ mod = importlib.import_module(f"training.models.{model_name}")
43
+ return mod.AnemiaModel(
44
+ dropout_rate=config["model"]["dropout_rate"],
45
+ pretrained=True,
46
+ )
47
+
48
+
49
+ def multitask_loss(
50
+ hb_pred: torch.Tensor,
51
+ hb_true: torch.Tensor,
52
+ class_logits: torch.Tensor,
53
+ class_true: torch.Tensor,
54
+ w_reg: float = 0.7,
55
+ w_cls: float = 0.3,
56
+ ) -> torch.Tensor:
57
+ mse = nn.functional.mse_loss(hb_pred.squeeze(), hb_true.float())
58
+ ce = nn.functional.cross_entropy(class_logits, class_true.long())
59
+ return w_reg * mse + w_cls * ce
60
+
61
+
62
+ def run_epoch(model, loader, optimizer, device, training: bool, config: dict):
63
+ model.train() if training else model.eval()
64
+ total_loss, hb_preds, hb_trues, cls_preds, cls_trues = 0.0, [], [], [], []
65
+ w_reg = config["training"]["loss_regression_weight"]
66
+ w_cls = config["training"]["loss_classification_weight"]
67
+
68
+ ctx = torch.enable_grad() if training else torch.no_grad()
69
+ with ctx:
70
+ for imgs, hb, cls in loader:
71
+ imgs, hb, cls = imgs.to(device), hb.to(device), cls.to(device)
72
+ if training:
73
+ optimizer.zero_grad()
74
+ hb_pred, cls_logits = model(imgs)
75
+ loss = multitask_loss(hb_pred, hb, cls_logits, cls, w_reg, w_cls)
76
+ if training:
77
+ loss.backward()
78
+ optimizer.step()
79
+ total_loss += loss.item()
80
+ hb_preds.extend(hb_pred.squeeze(1).cpu().numpy().tolist())
81
+ hb_trues.extend(hb.cpu().numpy().tolist())
82
+ cls_preds.extend(torch.softmax(cls_logits, dim=1).cpu().numpy().tolist())
83
+ cls_trues.extend(cls.cpu().numpy().tolist())
84
+
85
+ reg_metrics = compute_regression_metrics(np.array(hb_trues), np.array(hb_preds))
86
+ cls_metrics = compute_classification_metrics(np.array(cls_trues), np.array(cls_preds))
87
+ return {
88
+ "loss": total_loss / len(loader),
89
+ **reg_metrics,
90
+ "auc": cls_metrics["auc_macro"],
91
+ "f1": cls_metrics["f1_macro"],
92
+ }
93
+
94
+
95
+ def train_model(
96
+ model_name: str,
97
+ train_rows: list,
98
+ val_rows: list,
99
+ config: dict,
100
+ output_dir: pathlib.Path,
101
+ fold: int = 0,
102
+ run_name: str = "",
103
+ ) -> dict:
104
+ """Full two-phase training. Returns best val metrics dict."""
105
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
106
+ log.info(f"Training {model_name} fold={fold} on {device}")
107
+
108
+ img_size = config["data"]["image_size"]
109
+ train_ds = AnemiaDataset(train_rows, image_size=img_size, augment=True)
110
+ val_ds = AnemiaDataset(val_rows, image_size=img_size, augment=False)
111
+ train_loader = DataLoader(
112
+ train_ds,
113
+ batch_size=config["data"]["batch_size"],
114
+ shuffle=True,
115
+ num_workers=config["data"]["num_workers"],
116
+ pin_memory=True,
117
+ )
118
+ val_loader = DataLoader(
119
+ val_ds,
120
+ batch_size=config["data"]["batch_size"],
121
+ shuffle=False,
122
+ num_workers=config["data"]["num_workers"],
123
+ pin_memory=True,
124
+ )
125
+
126
+ model = get_model(model_name, config).to(device)
127
+
128
+ wandb_run = wandb.init(
129
+ project=config["wandb"]["project"],
130
+ name=run_name or f"{model_name}_fold{fold}",
131
+ config=config,
132
+ reinit=True,
133
+ )
134
+
135
+ # Phase 1: freeze backbone, train heads
136
+ model.freeze_backbone()
137
+ optimizer = torch.optim.AdamW(
138
+ filter(lambda p: p.requires_grad, model.parameters()),
139
+ lr=config["training"]["phase1_lr"],
140
+ weight_decay=config["training"]["weight_decay"],
141
+ )
142
+ log.info("Phase 1: training heads only")
143
+ for epoch in range(config["training"]["phase1_epochs"]):
144
+ train_m = run_epoch(model, train_loader, optimizer, device, training=True, config=config)
145
+ val_m = run_epoch(model, val_loader, optimizer, device, training=False, config=config)
146
+ wandb.log(
147
+ {
148
+ "epoch": epoch,
149
+ **{f"train/{k}": v for k, v in train_m.items()},
150
+ **{f"val/{k}": v for k, v in val_m.items()},
151
+ }
152
+ )
153
+ log.info(
154
+ f" Phase1 Ep{epoch + 1}: train_mae={train_m['mae']:.3f} val_mae={val_m['mae']:.3f}"
155
+ )
156
+
157
+ # Phase 2: unfreeze last 3 blocks
158
+ arch_cfg = next(
159
+ (a for a in config["model"]["architectures"] if a["name"] == model_name),
160
+ config["model"]["architectures"][0],
161
+ )
162
+ model.unfreeze_last_n_blocks(arch_cfg["unfreeze_last_n_blocks"])
163
+ optimizer = torch.optim.AdamW(
164
+ filter(lambda p: p.requires_grad, model.parameters()),
165
+ lr=config["training"]["phase2_lr"],
166
+ weight_decay=config["training"]["weight_decay"],
167
+ )
168
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
169
+ optimizer, T_max=config["training"]["phase2_epochs"]
170
+ )
171
+
172
+ best_val_mae = float("inf")
173
+ patience_count = 0
174
+ best_metrics = {}
175
+ best_ckpt_path = output_dir / f"{model_name}_fold{fold}_best.safetensors"
176
+
177
+ log.info("Phase 2: fine-tuning last 3 blocks")
178
+ for epoch in range(config["training"]["phase2_epochs"]):
179
+ train_m = run_epoch(model, train_loader, optimizer, device, training=True, config=config)
180
+ val_m = run_epoch(model, val_loader, optimizer, device, training=False, config=config)
181
+ scheduler.step()
182
+ wandb.log(
183
+ {
184
+ "epoch": epoch + config["training"]["phase1_epochs"],
185
+ **{f"train/{k}": v for k, v in train_m.items()},
186
+ **{f"val/{k}": v for k, v in val_m.items()},
187
+ }
188
+ )
189
+ log.info(f" Phase2 Ep{epoch + 1}: val_mae={val_m['mae']:.3f} val_auc={val_m['auc']:.3f}")
190
+
191
+ if val_m["mae"] < best_val_mae:
192
+ best_val_mae = val_m["mae"]
193
+ best_metrics = val_m
194
+ patience_count = 0
195
+ _save_safetensors(model, best_ckpt_path)
196
+ else:
197
+ patience_count += 1
198
+ if patience_count >= config["training"]["early_stopping_patience"]:
199
+ log.info(f" Early stopping at epoch {epoch + 1}")
200
+ break
201
+
202
+ wandb_run.finish()
203
+ metrics_path = output_dir / f"{model_name}_fold{fold}_metrics.json"
204
+ with open(metrics_path, "w") as f:
205
+ json.dump(best_metrics, f, indent=2)
206
+ log.info(f"Best val MAE: {best_val_mae:.3f} -- saved to {best_ckpt_path}")
207
+ return best_metrics
208
+
209
+
210
+ def _save_safetensors(model: nn.Module, path: pathlib.Path):
211
+ from safetensors.torch import save_file
212
+
213
+ path.parent.mkdir(parents=True, exist_ok=True)
214
+ save_file({k: v.contiguous() for k, v in model.state_dict().items()}, str(path))
215
+
216
+
217
+ def main():
218
+ parser = argparse.ArgumentParser()
219
+ parser.add_argument("--model", default="efficientnet_b4")
220
+ parser.add_argument("--config", default="training/config.yaml", type=pathlib.Path)
221
+ parser.add_argument("--output-dir", default="outputs/", type=pathlib.Path)
222
+ args = parser.parse_args()
223
+ load_config(args.config)
224
+ log.info(f"Config loaded: {args.config}")
225
+ log.info("Pass train_rows and val_rows to train_model() to start training.")
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()
training/utils/__init__.py ADDED
File without changes
training/utils/augmentation.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # training/utils/augmentation.py
2
+ """Albumentations pipelines for training and validation."""
3
+
4
+ import albumentations as A
5
+
6
+
7
+ def get_augmentation_pipeline(image_size: int = 380) -> A.Compose:
8
+ return A.Compose(
9
+ [
10
+ A.Resize(image_size, image_size),
11
+ A.HorizontalFlip(p=0.5),
12
+ A.Rotate(limit=15, p=0.7),
13
+ A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.6),
14
+ A.HueSaturationValue(hue_shift_limit=10, sat_shift_limit=15, val_shift_limit=10, p=0.4),
15
+ A.GaussNoise(var_limit=(10, 50), p=0.2),
16
+ A.CoarseDropout(max_holes=4, max_height=32, max_width=32, p=0.3),
17
+ ]
18
+ )
19
+
20
+
21
+ def get_val_transforms(image_size: int = 380) -> A.Compose:
22
+ return A.Compose(
23
+ [
24
+ A.Resize(image_size, image_size),
25
+ ]
26
+ )
training/utils/dataset.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # training/utils/dataset.py
2
+ """PyTorch Dataset for anemia screening images."""
3
+
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ import torch
8
+ from PIL import Image
9
+ from torch.utils.data import Dataset
10
+
11
+ from training.utils.augmentation import get_augmentation_pipeline, get_val_transforms
12
+
13
+ CLASS_TO_IDX = {"normal": 0, "mild": 1, "moderate": 2, "severe": 3}
14
+ IDX_TO_CLASS = {v: k for k, v in CLASS_TO_IDX.items()}
15
+
16
+
17
+ class AnemiaDataset(Dataset):
18
+ """
19
+ Dataset wrapping a list of HuggingFace-style row dicts.
20
+
21
+ Each row must have:
22
+ image : PIL Image
23
+ hb_value : float
24
+ anemia_class: str (normal | mild | moderate | severe)
25
+ """
26
+
27
+ def __init__(self, rows: list[dict[str, Any]], image_size: int = 380, augment: bool = False):
28
+ self.rows = rows
29
+ self.image_size = image_size
30
+ self.transform = (
31
+ get_augmentation_pipeline(image_size) if augment else get_val_transforms(image_size)
32
+ )
33
+
34
+ def __len__(self) -> int:
35
+ return len(self.rows)
36
+
37
+ def __getitem__(self, idx: int) -> tuple[torch.Tensor, float, int]:
38
+ row = self.rows[idx]
39
+ img = row["image"]
40
+ if not isinstance(img, Image.Image):
41
+ img = Image.fromarray(np.array(img))
42
+ img = img.convert("RGB")
43
+ img_arr = np.array(img)
44
+
45
+ transformed = self.transform(image=img_arr)
46
+ img_tensor = torch.from_numpy(transformed["image"]).permute(2, 0, 1).float() / 255.0
47
+
48
+ # Normalize with ImageNet stats
49
+ mean = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1)
50
+ std = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1)
51
+ img_tensor = (img_tensor - mean) / std
52
+
53
+ hb_val = float(row["hb_value"]) if row["hb_value"] is not None else 0.0
54
+ cls_idx = CLASS_TO_IDX.get(row.get("anemia_class", "normal"), 0)
55
+ return img_tensor, hb_val, cls_idx