ds-EkaCare commited on
Commit
29467f3
·
verified ·
1 Parent(s): 4748749

eka-doc-classifier: base1024 hier (flat L2 default + scope/l1 select, quality 1-100 score)

Browse files
README.md ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ pipeline_tag: image-classification
4
+ tags:
5
+ - siglip2
6
+ - document-classification
7
+ - hierarchical
8
+ - multi-task
9
+ library_name: transformers
10
+ ---
11
+
12
+ # Hierarchical SigLIP2 Document Classifier (~97M)
13
+
14
+ A **frozen SigLIP2 base** vision tower (`google/siglip2-base-patch16-naflex`, single
15
+ high-res forward at `max_num_patches=1024`) plus lightweight heads that classify a
16
+ document image across several tasks from one shared pooled embedding:
17
+
18
+ | Task | Output |
19
+ |------|--------|
20
+ | `l2` (flat, **default**) | leaf class — flat 27-way head; **L1 inferred** from it |
21
+ | `l1` | document group — one of 5 (Document, Miscellaneous, Diagnostic imaging, Card / credential, Body images) |
22
+ | `l2` (leaf) | leaf class via a per-L1 head, when the group is fixed/predicted |
23
+ | `medical` | sigmoid — `medical` / `non_medical` (+ `p_positive`) |
24
+ | `handwritten` | sigmoid — `handwritten` / `printed` (+ `p_positive`); tuned on documents |
25
+ | `quality` | sigmoid — a single **`score` in 1–100** (P(good) mapped onto 1–100; higher = better), no label |
26
+
27
+ ## L2 has two paths, selected by `scope`
28
+
29
+ There is **both** a flat 27-way L2 head (the backbone's own end-to-end classifier,
30
+ run on the raw pooled feature) **and** a per-L1 leaf head per group (run on the
31
+ standardized embedding). You choose how L2 / L1 are produced:
32
+
33
+ | `scope` | what runs | L1 | L2 |
34
+ |---------|-----------|----|----|
35
+ | `"flat"` *(default)* | flat 27-way head | **inferred** from the L2 prediction | flat |
36
+ | `"l1"` | L1 group head only | predicted | — (not produced) |
37
+ | `"hierarchical"` | L1 head → that group's leaf head | predicted | leaf |
38
+
39
+ **Fix the group yourself** by passing `l1="Document"` (any group name): L2 is then
40
+ read from that group's leaf head, overriding the scope's routing.
41
+
42
+ ```
43
+ image → frozen SigLIP2 tower → pooled embedding ──► flat L2 head ─► L2 (+ inferred L1) [scope="flat"]
44
+
45
+ ├─ standardize (mu/sd) → L1 head ─► L1 [scope="l1"]
46
+ │ └─► leaf L2 head[L1] ─► L2 [scope="hierarchical" / fixed l1]
47
+ └─ medical · handwritten · quality (sigmoid heads, independent)
48
+ ```
49
+
50
+ ### Taxonomy (5 L1 groups → 27 L2 leaves)
51
+
52
+ ```
53
+ Document (10 leaves)
54
+ ├─ Letter (key: letter)
55
+ ├─ Other document (key: other_document)
56
+ ├─ Scan interpretation (key: scan_interpretation)
57
+ ├─ Lab / diagnostic report (key: lab_diagnostic_report)
58
+ ├─ OPD Consultation Record (key: opd_consultation_record)
59
+ ├─ Discharge summary (key: discharge_summary)
60
+ ├─ Insurance document (key: insurance_document)
61
+ ├─ Form (key: form)
62
+ ├─ Invoice / bill (key: invoice_bill)
63
+ ├─ Certificate (key: certificate)
64
+ Miscellaneous (2 leaves)
65
+ ├─ Other (Miscellaneous) (key: other_miscellaneous)
66
+ ├─ Medication image (key: medication_image)
67
+ Diagnostic imaging (5 leaves)
68
+ ├─ Ultrasound (key: ultrasound)
69
+ ├─ X-ray (key: x_ray)
70
+ ├─ MRI (key: mri)
71
+ ├─ CT (key: ct)
72
+ ├─ Other (Diagnostic imaging) (key: other_diagnostic_imaging)
73
+ Card / credential (7 leaves)
74
+ ├─ ABHA card (key: abha_card)
75
+ ├─ Voter ID (key: voter_id)
76
+ ├─ Aadhaar card (key: aadhaar_card)
77
+ ├─ Insurance e-card (key: insurance_e_card)
78
+ ├─ PAN card (key: pan_card)
79
+ ├─ PMJAY / Ayushman (key: pmjay_ayushman)
80
+ ├─ Other card (key: other_card)
81
+ Body images (3 leaves)
82
+ ├─ Headshot (key: headshot)
83
+ ├─ Skin / wound (key: skin_wound)
84
+ ├─ Other (Body images) (key: other_body_images)
85
+ ```
86
+
87
+ ## Usage
88
+
89
+ ```python
90
+ from transformers import AutoModel
91
+ from PIL import Image
92
+
93
+ model = AutoModel.from_pretrained("ekacare/eka-doc-classifier", trust_remote_code=True).eval()
94
+ img = Image.open("doc.jpg")
95
+
96
+ model.classify(img) # default: flat L2 + inferred L1 + binaries
97
+ ```
98
+
99
+ ```jsonc
100
+ // example return value (scope="flat")
101
+ {
102
+ "l2": { "source": "flat", "key": "lab_diagnostic_report",
103
+ "value": "Lab / diagnostic report", "confidence": 0.97 },
104
+ "l1": { "value": "Document", "source": "inferred_from_flat_l2" },
105
+ "medical": { "value": "medical", "confidence": 0.99, "p_positive": 0.99 },
106
+ "handwritten": { "value": "printed", "confidence": 0.98, "p_positive": 0.02 },
107
+ "quality": { "score": 94, "p_good": 0.94 }
108
+ }
109
+ ```
110
+
111
+ ### Choosing the L2 scope
112
+
113
+ ```python
114
+ model.classify(img) # scope="flat" (default): flat L2, L1 inferred
115
+ model.classify(img, scope="l1") # L1 group only (no L2)
116
+ model.classify(img, scope="hierarchical") # L1 head → leaf L2
117
+ model.classify(img, l1="Document") # FIX L1 = Document → that group's leaf L2
118
+ model.classify(img, medical=False, handwritten=False, quality=False) # L2/L1 only
119
+ ```
120
+
121
+ `medical`, `handwritten`, `quality` are independent sigmoid heads — toggle them
122
+ freely. `quality` reports a single **`score` in 1–100** (the sigmoid P(good) mapped
123
+ onto the range; higher = better quality) plus the raw `p_good` — there is no
124
+ poor/good label. `handwritten` is trained on document images.
125
+
126
+ ### Top-k
127
+
128
+ ```python
129
+ model.classify(img, top_k=3) # top-3 candidates for the multi-class heads
130
+ ```
131
+
132
+ ## Quantized loading (optimum-quanto — CPU & GPU)
133
+
134
+ int8 **and int4** run on CPU as well as GPU. Only the vision tower is quantized;
135
+ `patch_embedding` and the heads stay full precision.
136
+
137
+ ```python
138
+ # pip install optimum-quanto
139
+ from modeling_siglip2_hier import load_classifier # downloaded by trust_remote_code
140
+
141
+ model = load_classifier("ekacare/eka-doc-classifier", quantization="int4") # None | "int8" | "int4"
142
+ model.classify(img)
143
+ ```
144
+
145
+ Flat-L2 accuracy by precision (vision tower quantized; full internal test = 16,204
146
+ images, external OOD = 953; see `benchmark_results.json`):
147
+
148
+ | precision | internal acc / mF1 (cluster) | external acc / mF1 (cluster) |
149
+ |-----------|------------------------------|------------------------------|
150
+ | bf16 | 96.45 / 0.965 (97.36) | 80.90 / 0.702 (90.95) |
151
+ | int8 | 96.43 / 0.965 (97.36) | 80.90 / 0.653 (91.15) |
152
+ | int4 | 95.22 / 0.955 (94.23) | 77.23 / 0.630 (85.60) |
153
+
154
+ **int8 is effectively lossless on accuracy** (internal −0.02, external acc identical;
155
+ only the external macro-F1 dips as rare classes lose a little balance). **int4** trades
156
+ ~1.2pt internal / ~3.7pt external for the smallest footprint. Base weights ship in bf16
157
+ (~193 MB); int8/int4 quantize the vision tower only (heads + `patch_embedding` stay fp).
benchmark_results.json ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "flat_l2": {
3
+ "bf16": {
4
+ "internal": {
5
+ "acc": 96.45,
6
+ "macro_f1": 0.9646,
7
+ "cluster_acc": 97.36,
8
+ "n": 16204
9
+ },
10
+ "external": {
11
+ "acc": 80.9,
12
+ "macro_f1": 0.7019,
13
+ "cluster_acc": 90.95,
14
+ "n": 953
15
+ },
16
+ "img_per_s": 78.4
17
+ },
18
+ "int8": {
19
+ "internal": {
20
+ "acc": 96.43,
21
+ "macro_f1": 0.9645,
22
+ "cluster_acc": 97.36,
23
+ "n": 16204
24
+ },
25
+ "external": {
26
+ "acc": 80.9,
27
+ "macro_f1": 0.6527,
28
+ "cluster_acc": 91.15,
29
+ "n": 953
30
+ },
31
+ "img_per_s": 69.3
32
+ },
33
+ "int4": {
34
+ "internal": {
35
+ "acc": 95.22,
36
+ "macro_f1": 0.9553,
37
+ "cluster_acc": 94.23,
38
+ "n": 16204
39
+ },
40
+ "external": {
41
+ "acc": 77.23,
42
+ "macro_f1": 0.6304,
43
+ "cluster_acc": 85.6,
44
+ "n": 953
45
+ },
46
+ "img_per_s": 24.8
47
+ }
48
+ },
49
+ "internal_n": 16204,
50
+ "external_n": 953
51
+ }
config.json ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Siglip2HierForDocClassification"
4
+ ],
5
+ "auto_map": {
6
+ "AutoConfig": "configuration_siglip2_hier.Siglip2HierConfig",
7
+ "AutoModel": "modeling_siglip2_hier.Siglip2HierForDocClassification"
8
+ },
9
+ "base_model_id": "google/siglip2-base-patch16-naflex",
10
+ "default_scope": "flat",
11
+ "dtype": "bfloat16",
12
+ "flat_l2_classes": [
13
+ "letter",
14
+ "other_document",
15
+ "scan_interpretation",
16
+ "lab_diagnostic_report",
17
+ "opd_consultation_record",
18
+ "discharge_summary",
19
+ "insurance_document",
20
+ "form",
21
+ "invoice_bill",
22
+ "certificate",
23
+ "other_miscellaneous",
24
+ "medication_image",
25
+ "ultrasound",
26
+ "x_ray",
27
+ "mri",
28
+ "ct",
29
+ "other_diagnostic_imaging",
30
+ "abha_card",
31
+ "voter_id",
32
+ "aadhaar_card",
33
+ "insurance_e_card",
34
+ "pan_card",
35
+ "pmjay_ayushman",
36
+ "other_card",
37
+ "headshot",
38
+ "skin_wound",
39
+ "other_body_images"
40
+ ],
41
+ "flat_l2_to_l1": {
42
+ "aadhaar_card": "Card / credential",
43
+ "abha_card": "Card / credential",
44
+ "certificate": "Document",
45
+ "ct": "Diagnostic imaging",
46
+ "discharge_summary": "Document",
47
+ "form": "Document",
48
+ "headshot": "Body images",
49
+ "insurance_document": "Document",
50
+ "insurance_e_card": "Card / credential",
51
+ "invoice_bill": "Document",
52
+ "lab_diagnostic_report": "Document",
53
+ "letter": "Document",
54
+ "medication_image": "Miscellaneous",
55
+ "mri": "Diagnostic imaging",
56
+ "opd_consultation_record": "Document",
57
+ "other_body_images": "Body images",
58
+ "other_card": "Card / credential",
59
+ "other_diagnostic_imaging": "Diagnostic imaging",
60
+ "other_document": "Document",
61
+ "other_miscellaneous": "Miscellaneous",
62
+ "pan_card": "Card / credential",
63
+ "pmjay_ayushman": "Card / credential",
64
+ "scan_interpretation": "Document",
65
+ "skin_wound": "Body images",
66
+ "ultrasound": "Diagnostic imaging",
67
+ "voter_id": "Card / credential",
68
+ "x_ray": "Diagnostic imaging"
69
+ },
70
+ "hand_classes": [
71
+ "printed",
72
+ "handwritten"
73
+ ],
74
+ "head_hidden": 512,
75
+ "hidden_size": 768,
76
+ "l1_classes": [
77
+ "Document",
78
+ "Miscellaneous",
79
+ "Diagnostic imaging",
80
+ "Card / credential",
81
+ "Body images"
82
+ ],
83
+ "l2_by_l1": {
84
+ "Body images": [
85
+ "headshot",
86
+ "skin_wound",
87
+ "other_body_images"
88
+ ],
89
+ "Card / credential": [
90
+ "abha_card",
91
+ "voter_id",
92
+ "aadhaar_card",
93
+ "insurance_e_card",
94
+ "pan_card",
95
+ "pmjay_ayushman",
96
+ "other_card"
97
+ ],
98
+ "Diagnostic imaging": [
99
+ "ultrasound",
100
+ "x_ray",
101
+ "mri",
102
+ "ct",
103
+ "other_diagnostic_imaging"
104
+ ],
105
+ "Document": [
106
+ "letter",
107
+ "other_document",
108
+ "scan_interpretation",
109
+ "lab_diagnostic_report",
110
+ "opd_consultation_record",
111
+ "discharge_summary",
112
+ "insurance_document",
113
+ "form",
114
+ "invoice_bill",
115
+ "certificate"
116
+ ],
117
+ "Miscellaneous": [
118
+ "other_miscellaneous",
119
+ "medication_image"
120
+ ]
121
+ },
122
+ "l2_display": {
123
+ "aadhaar_card": "Aadhaar card",
124
+ "abha_card": "ABHA card",
125
+ "certificate": "Certificate",
126
+ "ct": "CT",
127
+ "discharge_summary": "Discharge summary",
128
+ "form": "Form",
129
+ "headshot": "Headshot",
130
+ "insurance_document": "Insurance document",
131
+ "insurance_e_card": "Insurance e-card",
132
+ "invoice_bill": "Invoice / bill",
133
+ "lab_diagnostic_report": "Lab / diagnostic report",
134
+ "letter": "Letter",
135
+ "medication_image": "Medication image",
136
+ "mri": "MRI",
137
+ "opd_consultation_record": "OPD Consultation Record",
138
+ "other_body_images": "Other (Body images)",
139
+ "other_card": "Other card",
140
+ "other_diagnostic_imaging": "Other (Diagnostic imaging)",
141
+ "other_document": "Other document",
142
+ "other_miscellaneous": "Other (Miscellaneous)",
143
+ "pan_card": "PAN card",
144
+ "pmjay_ayushman": "PMJAY / Ayushman",
145
+ "scan_interpretation": "Scan interpretation",
146
+ "skin_wound": "Skin / wound",
147
+ "ultrasound": "Ultrasound",
148
+ "voter_id": "Voter ID",
149
+ "x_ray": "X-ray"
150
+ },
151
+ "max_num_patches": 1024,
152
+ "med_classes": [
153
+ "non_medical",
154
+ "medical"
155
+ ],
156
+ "model_type": "siglip2_hier_doc",
157
+ "qual_classes": [
158
+ "poor",
159
+ "good"
160
+ ],
161
+ "quality_score_label": "good",
162
+ "quality_score_max": 100,
163
+ "quality_score_min": 1,
164
+ "sigmoid_heads": [
165
+ "med",
166
+ "hand",
167
+ "qual"
168
+ ],
169
+ "transformers_version": "5.10.2",
170
+ "vision_config": {
171
+ "_name_or_path": "",
172
+ "architectures": null,
173
+ "attention_dropout": 0.0,
174
+ "chunk_size_feed_forward": 0,
175
+ "dtype": null,
176
+ "hidden_act": "gelu_pytorch_tanh",
177
+ "hidden_size": 768,
178
+ "id2label": {
179
+ "0": "LABEL_0",
180
+ "1": "LABEL_1"
181
+ },
182
+ "intermediate_size": 3072,
183
+ "is_encoder_decoder": false,
184
+ "label2id": {
185
+ "LABEL_0": 0,
186
+ "LABEL_1": 1
187
+ },
188
+ "layer_norm_eps": 1e-06,
189
+ "model_type": "siglip2_vision_model",
190
+ "num_attention_heads": 12,
191
+ "num_channels": 3,
192
+ "num_hidden_layers": 12,
193
+ "num_patches": 256,
194
+ "output_attentions": false,
195
+ "output_hidden_states": false,
196
+ "patch_size": 16,
197
+ "problem_type": null,
198
+ "return_dict": true,
199
+ "transformers_version": "5.10.2"
200
+ }
201
+ }
configuration_siglip2_hier.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Config for the hierarchical SigLIP2 document classifier.
2
+
3
+ Self-contained on purpose: every piece of taxonomy/metadata needed at inference
4
+ (L1 groups, the L2 leaves under each L1, the flat-L2 label space, the binary/quality
5
+ class orders, and the human-readable display names) is stored on the config, so the
6
+ published model repo does not depend on the parrotlet package.
7
+
8
+ Two L2 paths are shipped:
9
+ * a **flat** 27-way L2 classifier (the backbone's own end-to-end head, run on the
10
+ RAW pooled feature) — this is the DEFAULT path; L1 is *inferred* from the flat
11
+ prediction via ``flat_l2_to_l1``.
12
+ * per-L1 **leaf** L2 heads (run on the standardized embedding) — used when the
13
+ caller fixes the L1 group, or asks for ``scope="hierarchical"``.
14
+
15
+ The medical / handwritten heads are single-logit **sigmoid** heads; ``*_classes`` is
16
+ ordered ``[negative, positive]`` and the sigmoid value is P(positive). The ``quality``
17
+ head is also a sigmoid, but is reported **only** as a single continuous ``score`` in
18
+ ``[quality_score_min, quality_score_max]`` (default 1–100) — no poor/good label —
19
+ where ``score = round(min + P(good) * (max - min))``.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from typing import Dict, List, Optional
24
+
25
+ from transformers import PretrainedConfig
26
+
27
+
28
+ class Siglip2HierConfig(PretrainedConfig):
29
+ model_type = "siglip2_hier_doc"
30
+
31
+ def __init__(
32
+ self,
33
+ base_model_id: str = "google/siglip2-base-patch16-naflex",
34
+ vision_config: Optional[dict] = None,
35
+ hidden_size: int = 768,
36
+ head_hidden: int = 512,
37
+ max_num_patches: int = 1024,
38
+ l1_classes: Optional[List[str]] = None,
39
+ l2_by_l1: Optional[Dict[str, List[str]]] = None,
40
+ flat_l2_classes: Optional[List[str]] = None,
41
+ flat_l2_to_l1: Optional[Dict[str, str]] = None,
42
+ med_classes: Optional[List[str]] = None,
43
+ hand_classes: Optional[List[str]] = None,
44
+ qual_classes: Optional[List[str]] = None,
45
+ sigmoid_heads: Optional[List[str]] = None,
46
+ quality_score_label: str = "good",
47
+ quality_score_min: int = 1,
48
+ quality_score_max: int = 100,
49
+ default_scope: str = "flat",
50
+ l2_display: Optional[Dict[str, str]] = None,
51
+ **kwargs,
52
+ ):
53
+ super().__init__(**kwargs)
54
+ self.base_model_id = base_model_id
55
+ self.vision_config = vision_config
56
+ self.hidden_size = hidden_size
57
+ self.head_hidden = head_hidden
58
+ self.max_num_patches = max_num_patches
59
+ # Taxonomy. l1_classes is the ordered L1 label space; l2_by_l1 maps each L1
60
+ # to its ordered list of leaf L2 keys (the per-L1 leaf-head output space).
61
+ self.l1_classes = list(l1_classes or [])
62
+ self.l2_by_l1 = {k: list(v) for k, v in (l2_by_l1 or {}).items()}
63
+ # Flat 27-way L2 head label space + key -> L1 group (for inferring L1 from
64
+ # the flat prediction in the default scope).
65
+ self.flat_l2_classes = list(flat_l2_classes or [])
66
+ self.flat_l2_to_l1 = dict(flat_l2_to_l1 or {})
67
+ # Binary heads: ordered [negative, positive]; sigmoid value is P(positive).
68
+ self.med_classes = list(med_classes or ["no", "yes"])
69
+ self.hand_classes = list(hand_classes or ["no", "yes"])
70
+ self.qual_classes = list(qual_classes or ["poor", "good"])
71
+ # Names of the heads that are single-logit sigmoid (vs softmax).
72
+ self.sigmoid_heads = list(sigmoid_heads or ["med", "hand", "qual"])
73
+ # The quality class whose sigmoid probability is reported as the score.
74
+ self.quality_score_label = quality_score_label
75
+ # quality is reported as a single number in [min, max] (no poor/good label):
76
+ # score = round(min + P(good) * (max - min)).
77
+ self.quality_score_min = int(quality_score_min)
78
+ self.quality_score_max = int(quality_score_max)
79
+ # Default L2 path: "flat" (flat head + inferred L1), "l1" (L1 head only),
80
+ # or "hierarchical" (L1 head -> leaf head).
81
+ self.default_scope = default_scope
82
+ # Optional pretty names for L2 keys (e.g. "x_ray" -> "X-ray").
83
+ self.l2_display = dict(l2_display or {})
example.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal usage example. Run: python example.py path/to/image.jpg"""
2
+ import sys
3
+ from transformers import AutoModel
4
+ from PIL import Image
5
+
6
+ model = AutoModel.from_pretrained(".", trust_remote_code=True).eval()
7
+ img = Image.open(sys.argv[1] if len(sys.argv) > 1 else "doc.jpg")
8
+
9
+ import json
10
+ print(json.dumps(model.classify(img), indent=2))
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:737a15c2d5173dd41233ec742aedee843ac714a1f0cdb2f82a8f6b62e17cb228
3
+ size 193058476
modeling_siglip2_hier.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hierarchical SigLIP2 document classifier as a self-contained HF model.
2
+
3
+ One pooled embedding of the (frozen) SigLIP2 tower drives several heads:
4
+
5
+ image -> SigLIP2 vision tower -> pooled [d]
6
+ flat L2 head (on the RAW pooled feature) -> 27-way leaf, L1 inferred
7
+ standardize (mu/sd) -> z, then:
8
+ L1 head -> 5-way document group
9
+ leaf L2 head[L1] -> leaf within a (predicted or fixed) L1 group
10
+ medical / handwritten -> sigmoid (P positive)
11
+ quality -> sigmoid (P good) reported as a 1-100 score
12
+
13
+ Three L2 *scopes* (``classify(..., scope=...)``):
14
+ * ``"flat"`` (DEFAULT): run the flat 27-way head; **L1 is inferred** from the
15
+ predicted L2 class. This is the backbone's own strong end-to-end path.
16
+ * ``"l1"``: run the L1 group head only (no L2).
17
+ * ``"hierarchical"``: L1 head picks the group, then that group's leaf L2 head.
18
+
19
+ Fixing the group: pass ``l1="Document"`` (any L1 name) to **fix the L1** and read L2
20
+ from that group's leaf head — regardless of scope.
21
+
22
+ Medical / handwritten are single-logit sigmoids (yes/no + ``p_positive``);
23
+ ``quality`` returns only a single ``score`` in 1-100 (P(good) mapped onto the range),
24
+ with no poor/good label.
25
+
26
+ Public API
27
+ ----------
28
+ model = AutoModel.from_pretrained(repo, trust_remote_code=True)
29
+ model.classify(image) # flat L2 + inferred L1 + binaries
30
+ model.classify(image, scope="l1") # L1 group only
31
+ model.classify(image, l1="Document") # fixed L1 -> leaf L2
32
+
33
+ Quantized loading (int8/int4) uses optimum-quanto; see ``load_classifier`` /
34
+ ``quantize_in_place`` at the bottom.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import os
39
+ from typing import Optional
40
+
41
+ import torch
42
+ import torch.nn as nn
43
+ from transformers import PreTrainedModel
44
+
45
+ from .configuration_siglip2_hier import Siglip2HierConfig
46
+
47
+
48
+ class _MLPHead(nn.Module):
49
+ """Linear -> GELU -> Dropout -> Linear. Must match the training-time head so
50
+ saved weights load 1:1. ``n_out=1`` for the sigmoid binary heads."""
51
+
52
+ def __init__(self, d_in: int, n_out: int, hidden: int = 512, dropout: float = 0.0):
53
+ super().__init__()
54
+ self.net = nn.Sequential(
55
+ nn.Linear(d_in, hidden), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden, n_out)
56
+ )
57
+
58
+ def forward(self, x):
59
+ return self.net(x)
60
+
61
+
62
+ class Siglip2HierForDocClassification(PreTrainedModel):
63
+ config_class = Siglip2HierConfig
64
+ main_input_name = "pixel_values"
65
+ _tied_weights_keys = []
66
+ _keys_to_ignore_on_load_missing = []
67
+
68
+ def __init__(self, config: Siglip2HierConfig):
69
+ super().__init__(config)
70
+ from transformers import Siglip2VisionConfig, Siglip2VisionModel
71
+
72
+ vc = config.vision_config
73
+ if isinstance(vc, dict):
74
+ vc = Siglip2VisionConfig(**vc)
75
+ # Only the vision tower of SigLIP2 — the text encoder is never instantiated.
76
+ self.vision = Siglip2VisionModel(vc) if vc is not None else None
77
+
78
+ d, hid = config.hidden_size, config.head_hidden
79
+ sig = set(config.sigmoid_heads)
80
+ self.l1_head = _MLPHead(d, len(config.l1_classes), hid)
81
+ # ModuleList aligned to config.l1_classes order (L1 names contain spaces /
82
+ # slashes, not valid ModuleDict keys).
83
+ self.leaf_heads = nn.ModuleList(
84
+ [_MLPHead(d, len(config.l2_by_l1[l1]), hid) for l1 in config.l1_classes]
85
+ )
86
+ # Flat 27-way L2 head = the backbone's own end-to-end classifier (a bare
87
+ # Linear), applied to the RAW pooled feature (NOT standardized).
88
+ self.flat_head = nn.Linear(d, len(config.flat_l2_classes)) if config.flat_l2_classes else None
89
+ # Binary heads: 1 logit when sigmoid, else len(classes).
90
+ self.med_head = _MLPHead(d, 1 if "med" in sig else len(config.med_classes), hid)
91
+ self.hand_head = _MLPHead(d, 1 if "hand" in sig else len(config.hand_classes), hid)
92
+ self.qual_head = _MLPHead(d, 1 if "qual" in sig else len(config.qual_classes), hid)
93
+
94
+ # Feature standardization fit on the training embeddings (fp32, always).
95
+ self.register_buffer("mu", torch.zeros(1, d, dtype=torch.float32))
96
+ self.register_buffer("sd", torch.ones(1, d, dtype=torch.float32))
97
+
98
+ self._processor = None
99
+ self.post_init()
100
+
101
+ # ------------------------------------------------------------------ utils
102
+ def gradient_checkpointing_enable(self, **kw):
103
+ if self.vision is not None:
104
+ self.vision.gradient_checkpointing_enable(**kw)
105
+
106
+ def set_processor(self, processor):
107
+ self._processor = processor
108
+ return self
109
+
110
+ def _get_processor(self):
111
+ if self._processor is None:
112
+ from transformers import AutoImageProcessor
113
+
114
+ src = self.config._name_or_path or self.name_or_path
115
+ if not src:
116
+ raise RuntimeError(
117
+ "No image processor available. Load from a repo/dir that ships a "
118
+ "preprocessor, or call model.set_processor(proc)."
119
+ )
120
+ proc = AutoImageProcessor.from_pretrained(src)
121
+ proc.max_num_patches = self.config.max_num_patches
122
+ self._processor = proc
123
+ return self._processor
124
+
125
+ @property
126
+ def _compute_dtype(self) -> torch.dtype:
127
+ dt = self.dtype # PreTrainedModel.dtype skips int (quantized) params
128
+ return dt if dt.is_floating_point else torch.float16
129
+
130
+ # --------------------------------------------------------------- forward
131
+ @torch.no_grad()
132
+ def _embed_raw(self, image) -> torch.Tensor:
133
+ """Image -> RAW pooled embedding [1, d] (fp32). One vision forward."""
134
+ from PIL import Image
135
+
136
+ if isinstance(image, (str, os.PathLike)):
137
+ image = Image.open(image)
138
+ elif hasattr(image, "read"): # file-like / bytes buffer
139
+ image = Image.open(image)
140
+ image = image.convert("RGB")
141
+
142
+ proc = self._get_processor()
143
+ enc = proc(images=[image], return_tensors="pt")
144
+ device, cdtype = self.device, self._compute_dtype
145
+ enc = {
146
+ k: (v.to(device=device, dtype=cdtype) if torch.is_floating_point(v) else v.to(device))
147
+ for k, v in enc.items()
148
+ }
149
+ return self.vision(**enc).pooler_output.float()
150
+
151
+ def _standardize(self, feat: torch.Tensor) -> torch.Tensor:
152
+ return (feat - self.mu.float()) / self.sd.float()
153
+
154
+ @torch.no_grad()
155
+ def embed(self, image) -> torch.Tensor:
156
+ """Back-compat: standardized pooled embedding [1, d] (fp32)."""
157
+ return self._standardize(self._embed_raw(image))
158
+
159
+ # --------------------------------------------------------- head helpers
160
+ def _softmax_probs(self, head: nn.Module, x: torch.Tensor) -> torch.Tensor:
161
+ logits = head(x.to(self._compute_dtype))
162
+ return torch.softmax(logits.float()[0], dim=-1)
163
+
164
+ def _sigmoid_pos(self, head: nn.Module, z: torch.Tensor) -> float:
165
+ """P(positive) from a single-logit sigmoid head."""
166
+ logit = head(z.to(self._compute_dtype)).float().reshape(-1)[0]
167
+ return float(torch.sigmoid(logit))
168
+
169
+ def _top(self, probs: torch.Tensor, classes, top_k: Optional[int]):
170
+ if top_k and top_k > 1:
171
+ k = min(top_k, probs.numel())
172
+ vals, idx = torch.topk(probs, k)
173
+ return [{"value": classes[int(i)], "confidence": round(float(v), 4)}
174
+ for v, i in zip(vals, idx)]
175
+ i = int(probs.argmax())
176
+ return {"value": classes[i], "confidence": round(float(probs[i]), 4)}
177
+
178
+ def _flat_l2(self, raw: torch.Tensor, top_k):
179
+ """Flat 27-way L2 on the RAW feature; infer L1 from the top prediction."""
180
+ probs = self._softmax_probs(self.flat_head, raw)
181
+ disp = self.config.l2_display
182
+ res = self._top(probs, self.config.flat_l2_classes, top_k)
183
+ if isinstance(res, list): # top_k
184
+ for r in res:
185
+ r["key"] = r["value"]
186
+ r["value"] = disp.get(r["key"], r["key"])
187
+ top_key = res[0]["key"]
188
+ l2 = {"source": "flat", "candidates": res}
189
+ else:
190
+ top_key = res["value"]
191
+ l2 = {"source": "flat", "key": top_key, "value": disp.get(top_key, top_key),
192
+ "confidence": res["confidence"]}
193
+ l1_name = self.config.flat_l2_to_l1.get(top_key)
194
+ return {"l2": l2, "l1": {"value": l1_name, "source": "inferred_from_flat_l2"}}
195
+
196
+ def _leaf_l2(self, z: torch.Tensor, l1_name: str, top_k):
197
+ """Leaf L2 within a given L1 group."""
198
+ idx = self.config.l1_classes.index(l1_name)
199
+ probs = self._softmax_probs(self.leaf_heads[idx], z)
200
+ leaf_classes = self.config.l2_by_l1[l1_name]
201
+ disp = self.config.l2_display
202
+ res = self._top(probs, leaf_classes, top_k)
203
+ if isinstance(res, list):
204
+ for r in res:
205
+ r["key"] = r["value"]
206
+ r["value"] = disp.get(r["key"], r["key"])
207
+ return {"source": "leaf", "l1": l1_name, "candidates": res}
208
+ key = res["value"]
209
+ return {"source": "leaf", "l1": l1_name, "key": key,
210
+ "value": disp.get(key, key), "confidence": res["confidence"]}
211
+
212
+ def _binary(self, name: str, z: torch.Tensor, head, classes):
213
+ """yes/no from a sigmoid head; classes = [negative, positive]."""
214
+ if name in self.config.sigmoid_heads:
215
+ p = self._sigmoid_pos(head, z) # P(positive)
216
+ else: # softmax fallback
217
+ probs = self._softmax_probs(head, z)
218
+ p = float(probs[-1])
219
+ pos, neg = classes[1], classes[0]
220
+ is_pos = p >= 0.5
221
+ return {"value": pos if is_pos else neg,
222
+ "confidence": round(p if is_pos else 1 - p, 4),
223
+ "p_positive": round(p, 4)}
224
+
225
+ def _quality(self, z: torch.Tensor) -> dict:
226
+ """Quality as a single number in [min, max] (default 1-100) — no poor/good label.
227
+
228
+ The sigmoid head's P(good) is linearly mapped onto the configured range:
229
+ ``score = round(min + P(good) * (max - min))`` (P=0 -> min, P=1 -> max)."""
230
+ p = self._sigmoid_pos(self.qual_head, z) # P(good)
231
+ if self.config.quality_score_label != self.config.qual_classes[1]:
232
+ p = 1.0 - p # score tracks the configured label
233
+ lo, hi = self.config.quality_score_min, self.config.quality_score_max
234
+ return {"score": int(round(lo + p * (hi - lo))), "p_good": round(p, 4)}
235
+
236
+ @torch.no_grad()
237
+ def classify(
238
+ self,
239
+ image,
240
+ scope: Optional[str] = None,
241
+ l1: Optional[str] = None,
242
+ medical: bool = True,
243
+ quality: bool = True,
244
+ handwritten: bool = True,
245
+ top_k: Optional[int] = None,
246
+ ) -> dict:
247
+ """Classify one image.
248
+
249
+ Args:
250
+ image: PIL.Image, path, or file-like.
251
+ scope: L2 path — ``"flat"`` (default; flat head, L1 inferred), ``"l1"``
252
+ (L1 group head only), or ``"hierarchical"`` (L1 head -> leaf head).
253
+ Defaults to ``config.default_scope``.
254
+ l1: fix the L1 group (any name in ``config.l1_classes``) and read L2 from
255
+ that group's leaf head — overrides scope's L1/L2 routing.
256
+ medical / handwritten: sigmoid binaries (also report ``p_positive``).
257
+ quality: sigmoid head; reports a single ``score`` in 1-100 (mapped from
258
+ P(good)) plus ``p_good`` — no poor/good label.
259
+ top_k: if >1, return the top-k candidates for the multi-class heads.
260
+
261
+ Returns:
262
+ dict keyed by task. ``l2``/``l1`` entries carry a ``source`` field
263
+ indicating how they were produced.
264
+ """
265
+ scope = (scope or self.config.default_scope).lower()
266
+ if l1 is not None and l1 not in self.config.l1_classes:
267
+ raise ValueError(f"unknown l1 group {l1!r}; choose from {self.config.l1_classes}")
268
+ if scope not in ("flat", "l1", "hierarchical"):
269
+ raise ValueError(f"unknown scope {scope!r}; use 'flat', 'l1', or 'hierarchical'.")
270
+ if scope == "flat" and self.flat_head is None and l1 is None:
271
+ raise ValueError("scope='flat' requires a flat L2 head; this model has none.")
272
+
273
+ raw = self._embed_raw(image) # single vision forward
274
+ z = self._standardize(raw)
275
+ out: dict = {}
276
+
277
+ if l1 is not None:
278
+ # Fixed L1 group -> leaf L2.
279
+ out["l1"] = {"value": l1, "source": "fixed"}
280
+ out["l2"] = self._leaf_l2(z, l1, top_k)
281
+ elif scope == "l1":
282
+ out["l1"] = {**self._top(self._softmax_probs(self.l1_head, z),
283
+ self.config.l1_classes, top_k), "source": "l1_head"}
284
+ elif scope == "flat":
285
+ out.update(self._flat_l2(raw, top_k))
286
+ else: # hierarchical
287
+ l1_probs = self._softmax_probs(self.l1_head, z)
288
+ l1_name = self.config.l1_classes[int(l1_probs.argmax())]
289
+ out["l1"] = {**self._top(l1_probs, self.config.l1_classes, top_k), "source": "l1_head"}
290
+ out["l2"] = self._leaf_l2(z, l1_name, top_k)
291
+
292
+ if medical:
293
+ out["medical"] = self._binary("med", z, self.med_head, self.config.med_classes)
294
+ if handwritten:
295
+ out["handwritten"] = self._binary("hand", z, self.hand_head, self.config.hand_classes)
296
+ if quality:
297
+ out["quality"] = self._quality(z)
298
+ return out
299
+
300
+
301
+ # --------------------------------------------------------------------------- #
302
+ # optimum-quanto quantization shortcuts (int8/int4, CPU & GPU).
303
+ # --------------------------------------------------------------------------- #
304
+ # patch_embedding is excluded — SigLIP2 casts the pixel input to its weight dtype,
305
+ # which an int weight breaks. Heads stay full precision (a few MB, decisive for
306
+ # accuracy); pass quantize_heads=True to override.
307
+ _EXCLUDE_FROM_QUANT = ["*patch_embedding*"]
308
+
309
+
310
+ def quantize_in_place(model, bits: int = 4, quantize_heads: bool = False):
311
+ """Quantize a loaded model's vision tower with optimum-quanto (int8 or int4)."""
312
+ from optimum.quanto import freeze, qint4, qint8, quantize
313
+
314
+ if bits not in (4, 8):
315
+ raise ValueError(f"bits must be 4 or 8, got {bits}")
316
+ weights = qint8 if bits == 8 else qint4
317
+ target = model if quantize_heads else model.vision
318
+ quantize(target, weights=weights, exclude=list(_EXCLUDE_FROM_QUANT))
319
+ freeze(target)
320
+ return model.eval()
321
+
322
+
323
+ def load_classifier(
324
+ model_id: str,
325
+ quantization: Optional[str] = None,
326
+ device: Optional[str] = None,
327
+ dtype: Optional[torch.dtype] = torch.bfloat16,
328
+ trust_remote_code: bool = True,
329
+ quantize_heads: bool = False,
330
+ **kwargs,
331
+ ):
332
+ """Load the hierarchical classifier, optionally quanto-quantized (int8/int4)."""
333
+ from transformers import AutoModel
334
+
335
+ model = AutoModel.from_pretrained(
336
+ model_id, trust_remote_code=trust_remote_code, dtype=dtype, **kwargs
337
+ ).eval()
338
+ if device:
339
+ model = model.to(device)
340
+
341
+ q = (quantization or "").lower()
342
+ if q in ("int8", "8bit", "8"):
343
+ quantize_in_place(model, bits=8, quantize_heads=quantize_heads)
344
+ elif q in ("int4", "4bit", "4"):
345
+ quantize_in_place(model, bits=4, quantize_heads=quantize_heads)
346
+ elif q not in ("", "none", "fp16", "bf16", "fp32"):
347
+ raise ValueError(f"Unknown quantization {quantization!r}; use None/int8/int4.")
348
+ return model.eval()
preprocessor_config.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "do_normalize": true,
3
+ "do_rescale": true,
4
+ "do_resize": true,
5
+ "image_mean": [
6
+ 0.5,
7
+ 0.5,
8
+ 0.5
9
+ ],
10
+ "image_processor_type": "Siglip2ImageProcessor",
11
+ "image_std": [
12
+ 0.5,
13
+ 0.5,
14
+ 0.5
15
+ ],
16
+ "max_num_patches": 1024,
17
+ "patch_size": 16,
18
+ "resample": 2,
19
+ "rescale_factor": 0.00392156862745098
20
+ }
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ torch>=2.7.0
2
+ transformers>=4.52.0
3
+ pillow
4
+ # Optional — only needed for int8/int4 quantized loading (runs on CPU and GPU):
5
+ optimum-quanto>=0.2.6