kykatro commited on
Commit
0e83a2b
·
verified ·
1 Parent(s): 0780d79

Add Mettle RC2 weights, loader, and model card

Browse files
README.md ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: other
3
+ base_model: bioptimus/H-optimus-0
4
+ library_name: transformers
5
+ pipeline_tag: feature-extraction
6
+ tags:
7
+ - computational-pathology
8
+ - histopathology
9
+ - foundation-model
10
+ - feature-extraction
11
+ - scanner-robustness
12
+ ---
13
+
14
+ # Mettle
15
+
16
+ Mettle is a histopathology tile encoder derived from H-optimus-0. It is designed to improve representation consistency across slide scanners and staining variation while retaining biologically useful morphology.
17
+
18
+ Mettle is intended for research feature extraction. It is not a diagnostic system and has not been validated for autonomous clinical use.
19
+
20
+ ## Model details
21
+
22
+ | Item | Value |
23
+ |---|---|
24
+ | Parent model | [H-optimus-0](https://huggingface.co/bioptimus/H-optimus-0) |
25
+ | Parent-model license | Apache-2.0 |
26
+ | Architecture | ViT-g/14, DINOv2 reg4 |
27
+ | Input | 224 × 224 RGB histology tile |
28
+ | Compatibility output | 1,536-dimensional headed CLS embedding |
29
+ | Public benchmark output | 3,072-dimensional CLS+mean-patch embedding |
30
+ | `model.safetensors` SHA-256 | `7754ef463e79dd13b072247b56db39791c884db10774e354d76f76367093ce4a` |
31
+
32
+ The public benchmark representation concatenates the headed CLS embedding with the mean of the spatial patch tokens:
33
+
34
+ ```text
35
+ [headed CLS embedding (1536), mean spatial-patch embedding (1536)]
36
+ ```
37
+
38
+ CLS and register tokens are excluded from the patch mean. The first 1,536 coordinates are exactly the compatibility CLS representation.
39
+
40
+ ## Public benchmark results
41
+
42
+ All results in this table use the 3,072-dimensional CLS+mean representation. Higher is better. Values after `±` are standard deviations over five downstream probe runs. PathoROB Robustness Index is a point estimate without an uncertainty interval.
43
+
44
+ | Benchmark | Metric | Mettle | H-optimus-0 |
45
+ |---|---|---:|---:|
46
+ | PathoROB | Mean Robustness Index | **0.92227** | 0.8015 |
47
+ | HEST | Mean Pearson, 9 tasks | **0.43032** | 0.42526 |
48
+ | EVA BACH | Accuracy | **0.78168** ± 0.00567 | 0.7294 |
49
+ | EVA CRC | Accuracy | **0.96912** | 0.9578 |
50
+ | EVA PatchCamelyon | Balanced accuracy | 0.9476 | 0.9441 |
51
+
52
+ ### PathoROB breakdown
53
+
54
+ | Cohort | Mettle |
55
+ |---|---:|
56
+ | Camelyon | 0.93859 |
57
+ | TCGA | 0.86521 |
58
+ | Tolkach ESCA | 0.96302 |
59
+ | **Mean** | **0.92227** |
60
+
61
+ PathoROB does not provide uncertainty estimates for its Robustness Index. Differences in point estimates should not be described as statistical superiority.
62
+
63
+ ### HEST task breakdown
64
+
65
+ HEST uses PCA to 256 dimensions followed by ridge regression for a 50-gene panel. The metric is Pearson correlation averaged over official splits.
66
+
67
+ | HEST task | Mettle | Published H-optimus-0 |
68
+ |---|---:|---:|
69
+ | IDC | 0.6104 | **0.6106** |
70
+ | PRAD | **0.3789** | 0.36215 |
71
+ | PAAD | **0.5309** | 0.5106 |
72
+ | SKCM | **0.6745** | 0.6614 |
73
+ | COAD | **0.3331** | 0.3089 |
74
+ | READ | 0.2372 | **0.2401** |
75
+ | CCRCC | 0.2552 | **0.2919** |
76
+ | LUNG | **0.5777** | 0.5754 |
77
+ | LYMPH_IDC | **0.2751** | 0.2665 |
78
+ | **Unweighted mean** | **0.4303** | **0.4253** |
79
+
80
+ ## Usage
81
+
82
+ Install the dependencies listed in `requirements.txt`, then load the image processor and model through the standard Transformers auto classes.
83
+
84
+ ```python
85
+ import torch
86
+ from PIL import Image
87
+ from transformers import AutoImageProcessor, AutoModel
88
+
89
+ model_id = "slideflow-labs/Mettle"
90
+ processor = AutoImageProcessor.from_pretrained(model_id)
91
+ model = AutoModel.from_pretrained(
92
+ model_id,
93
+ trust_remote_code=True,
94
+ ).eval().cuda()
95
+
96
+ image = Image.open("tile.png").convert("RGB")
97
+ pixel_values = processor(
98
+ images=image,
99
+ return_tensors="pt",
100
+ )["pixel_values"].cuda()
101
+
102
+ with torch.inference_mode():
103
+ # Representation used for the public benchmarks: (batch, 3072)
104
+ cls_mean = model.encode(pixel_values, feature_view="cls_mean")
105
+
106
+ # Conventional compatibility representation: (batch, 1536)
107
+ cls = model.encode(pixel_values, feature_view="cls")
108
+ ```
109
+
110
+ ## Choosing a representation
111
+
112
+ The reported public benchmarks use the 3,072-dimensional CLS+mean representation. The loader also exposes the 1,536-dimensional CLS embedding for compatibility; CLS can also be stronger on some endpoints.
113
+
114
+ ## Intended use
115
+
116
+ Mettle is intended for research involving histopathology representation learning, feature extraction, model development, and robustness analysis. Users should validate the model on the intended tissue, acquisition hardware, population, and downstream endpoint.
117
+
118
+ Do not use Mettle as a standalone diagnostic system. Appropriate human oversight, validation, and compliance requirements remain necessary.
119
+
120
+ ## Limitations
121
+
122
+ - Performance can vary with tissue type, preparation, stain, scanner, compression, magnification, and downstream protocol.
123
+ - Scanner consistency does not guarantee preservation of every biological signal or subgroup.
124
+ - The two output representations are endpoint-dependent; neither dominates every benchmark.
125
+ - PathoROB Robustness Index is reported without uncertainty estimates.
126
+ - Benchmark results do not establish clinical efficacy or safety.
127
+ - The parent model's limitations and license obligations continue to apply.
128
+
129
+ ## References
130
+
131
+ - [H-optimus-0 model card](https://huggingface.co/bioptimus/H-optimus-0)
132
+ - [Towards robust foundation models for digital pathology
133
+ (PathoROB)](https://doi.org/10.1038/s41467-026-73923-2)
134
+ - [HEST-1k: A Dataset for Spatial Transcriptomics and Histology Image
135
+ Analysis](https://papers.neurips.cc/paper_files/paper/2024/file/60a899cc31f763be0bde781a75e04458-Paper-Datasets_and_Benchmarks_Track.pdf)
136
+ - [eva: Evaluation framework for pathology foundation
137
+ models](https://openreview.net/forum?id=FNBQOPj18N)
138
+
139
+ ## Citation
140
+
141
+ An arXiv preprint is forthcoming.
142
+
143
+ ## License
144
+
145
+ Mettle is licensed under CC-BY-NC-ND-4.0, and is available for academic, non-commercial use.
USAGE.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Mettle usage
2
+ ============
3
+
4
+ Install the dependencies in requirements.txt, then load a local checkout or
5
+ Hugging Face repository with the standard Transformers auto classes:
6
+
7
+ from PIL import Image
8
+ from transformers import AutoImageProcessor, AutoModel
9
+
10
+ repo = "slideflow-labs/Mettle"
11
+ processor = AutoImageProcessor.from_pretrained(repo)
12
+ model = AutoModel.from_pretrained(
13
+ repo,
14
+ trust_remote_code=True,
15
+ ).eval()
16
+
17
+ image = Image.open("tile.png").convert("RGB")
18
+ pixel_values = processor(images=image, return_tensors="pt").pixel_values
19
+
20
+ # Public benchmark representation: (batch, 3072)
21
+ embedding = model.encode(pixel_values, feature_view="cls_mean")
22
+
23
+ # Compatibility representation: (batch, 1536)
24
+ cls_embedding = model.encode(pixel_values, feature_view="cls")
25
+
26
+ The first 1536 coordinates of cls_mean are exactly cls_embedding. The remaining
27
+ 1536 coordinates are the mean of spatial patch tokens; CLS and register tokens
28
+ are excluded.
__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Mettle Hugging Face custom model."""
2
+
3
+ from .configuration_mettle import MettleConfig
4
+ from .modeling_mettle import MettleModel, MettleModelOutput
5
+
6
+ __all__ = ["MettleConfig", "MettleModel", "MettleModelOutput"]
config.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "MettleModel"
4
+ ],
5
+ "auto_map": {
6
+ "AutoConfig": "configuration_mettle.MettleConfig",
7
+ "AutoModel": "modeling_mettle.MettleModel"
8
+ },
9
+ "backbone_name": "vit_giant_patch14_reg4_dinov2",
10
+ "default_feature_view": "cls",
11
+ "head_enabled": true,
12
+ "head_hidden_size": 256,
13
+ "head_num_atoms": 16,
14
+ "head_rank": 8,
15
+ "hidden_size": 1536,
16
+ "image_size": 224,
17
+ "model_type": "mettle",
18
+ "num_register_tokens": 4,
19
+ "torch_dtype": "float32"
20
+ }
configuration_mettle.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for the Mettle pathology tile encoder."""
2
+
3
+ from transformers import PretrainedConfig
4
+
5
+
6
+ class MettleConfig(PretrainedConfig):
7
+ """Configuration for a Mettle ViT-g/14 encoder."""
8
+
9
+ model_type = "mettle"
10
+
11
+ def __init__(
12
+ self,
13
+ backbone_name="vit_giant_patch14_reg4_dinov2",
14
+ image_size=224,
15
+ hidden_size=1536,
16
+ num_register_tokens=4,
17
+ head_enabled=True,
18
+ head_num_atoms=16,
19
+ head_rank=8,
20
+ head_hidden_size=256,
21
+ default_feature_view="cls",
22
+ **kwargs,
23
+ ):
24
+ super().__init__(**kwargs)
25
+ if default_feature_view not in {"cls", "cls_mean"}:
26
+ raise ValueError(
27
+ "default_feature_view must be 'cls' or 'cls_mean', got "
28
+ f"{default_feature_view!r}"
29
+ )
30
+ self.backbone_name = backbone_name
31
+ self.image_size = int(image_size)
32
+ self.hidden_size = int(hidden_size)
33
+ self.num_register_tokens = int(num_register_tokens)
34
+ self.head_enabled = bool(head_enabled)
35
+ self.head_num_atoms = int(head_num_atoms)
36
+ self.head_rank = int(head_rank)
37
+ self.head_hidden_size = int(head_hidden_size)
38
+ self.default_feature_view = default_feature_view
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7754ef463e79dd13b072247b56db39791c884db10774e354d76f76367093ce4a
3
+ size 4545473592
modeling_mettle.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face model implementation for the Mettle tile encoder."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+ import timm
7
+ import torch
8
+ import torch.nn as nn
9
+ from transformers import PreTrainedModel
10
+ from transformers.utils import ModelOutput
11
+
12
+ try:
13
+ from .configuration_mettle import MettleConfig
14
+ except ImportError:
15
+ from configuration_mettle import MettleConfig
16
+
17
+
18
+ class MettleRefineHead(nn.Module):
19
+ """Content-conditional refinement of the pooled CLS embedding."""
20
+
21
+ def __init__(self, dim, num_atoms, rank, hidden_size):
22
+ super().__init__()
23
+ self.P = nn.Parameter(torch.zeros(num_atoms, dim, rank))
24
+ self.Q = nn.Parameter(torch.zeros(num_atoms, dim, rank))
25
+ self.mix = nn.Sequential(
26
+ nn.Linear(dim, hidden_size),
27
+ nn.GELU(),
28
+ nn.Linear(hidden_size, num_atoms),
29
+ )
30
+ self.shift = nn.Sequential(
31
+ nn.Linear(dim, hidden_size),
32
+ nn.GELU(),
33
+ nn.Linear(hidden_size, dim),
34
+ )
35
+
36
+ def forward(self, embedding):
37
+ mixture = self.mix(embedding)
38
+ shift = self.shift(embedding)
39
+ projected = torch.einsum("bd,kdr->bkr", embedding, self.P)
40
+ delta = torch.einsum("bkr,kdr->bkd", projected, self.Q)
41
+ return embedding + (mixture.unsqueeze(-1) * delta).sum(1) + shift
42
+
43
+
44
+ @dataclass
45
+ class MettleModelOutput(ModelOutput):
46
+ """Outputs from :class:`MettleModel`.
47
+
48
+ ``pooler_output`` follows the requested feature view. ``cls_embedding`` is
49
+ the headed 1536-dimensional CLS representation. ``cls_mean_embedding`` is
50
+ the concatenation of headed CLS and the unheaded mean spatial patch token.
51
+ """
52
+
53
+ last_hidden_state: Optional[torch.Tensor] = None
54
+ pooler_output: Optional[torch.Tensor] = None
55
+ cls_embedding: Optional[torch.Tensor] = None
56
+ mean_patch_embedding: Optional[torch.Tensor] = None
57
+ cls_mean_embedding: Optional[torch.Tensor] = None
58
+
59
+
60
+ class MettleModel(PreTrainedModel):
61
+ """Mettle scanner-resilient pathology tile encoder."""
62
+
63
+ config_class = MettleConfig
64
+ base_model_prefix = "backbone"
65
+ main_input_name = "pixel_values"
66
+
67
+ def __init__(self, config):
68
+ super().__init__(config)
69
+ self.backbone = timm.create_model(
70
+ config.backbone_name,
71
+ pretrained=False,
72
+ num_classes=0,
73
+ init_values=1e-5,
74
+ dynamic_img_size=False,
75
+ img_size=config.image_size,
76
+ )
77
+ if int(self.backbone.num_features) != config.hidden_size:
78
+ raise ValueError(
79
+ f"{config.backbone_name} has width {self.backbone.num_features}, "
80
+ f"but the config declares {config.hidden_size}"
81
+ )
82
+ self.head = None
83
+ if config.head_enabled:
84
+ self.head = MettleRefineHead(
85
+ dim=config.hidden_size,
86
+ num_atoms=config.head_num_atoms,
87
+ rank=config.head_rank,
88
+ hidden_size=config.head_hidden_size,
89
+ )
90
+ self.post_init()
91
+
92
+ def _embeddings(self, pixel_values):
93
+ tokens = self.backbone.forward_features(pixel_values)
94
+ expected_prefix = 1 + self.config.num_register_tokens
95
+ actual_prefix = int(self.backbone.num_prefix_tokens)
96
+ if actual_prefix != expected_prefix:
97
+ raise RuntimeError(
98
+ f"expected {expected_prefix} prefix tokens, found {actual_prefix}"
99
+ )
100
+ patches = tokens[:, actual_prefix:]
101
+ if patches.shape[1] == 0:
102
+ raise RuntimeError("the backbone returned no spatial patch tokens")
103
+
104
+ cls_embedding = tokens[:, 0].to(torch.float32)
105
+ if self.head is not None:
106
+ cls_embedding = self.head(cls_embedding)
107
+ mean_patch_embedding = patches.to(torch.float32).mean(dim=1)
108
+ cls_mean_embedding = torch.cat(
109
+ (cls_embedding, mean_patch_embedding),
110
+ dim=-1,
111
+ )
112
+ return tokens, cls_embedding, mean_patch_embedding, cls_mean_embedding
113
+
114
+ def forward(
115
+ self,
116
+ pixel_values,
117
+ feature_view=None,
118
+ return_dict=None,
119
+ ):
120
+ """Encode normalized 224-pixel RGB tiles.
121
+
122
+ Args:
123
+ pixel_values: Tensor with shape ``(batch, 3, 224, 224)`` after the
124
+ preprocessing specified by ``preprocessor_config.json``.
125
+ feature_view: ``"cls"`` for a 1536-dimensional embedding or
126
+ ``"cls_mean"`` for headed CLS concatenated with mean spatial
127
+ patch pooling (3072 dimensions).
128
+ return_dict: Follow the standard Transformers return convention.
129
+ """
130
+ feature_view = feature_view or self.config.default_feature_view
131
+ if feature_view not in {"cls", "cls_mean"}:
132
+ raise ValueError(
133
+ "feature_view must be 'cls' or 'cls_mean', got "
134
+ f"{feature_view!r}"
135
+ )
136
+ return_dict = (
137
+ self.config.use_return_dict if return_dict is None else return_dict
138
+ )
139
+ tokens, cls_embedding, mean_patch, cls_mean = self._embeddings(
140
+ pixel_values
141
+ )
142
+ selected = cls_embedding if feature_view == "cls" else cls_mean
143
+ if not return_dict:
144
+ return tokens, selected, cls_embedding, mean_patch, cls_mean
145
+ return MettleModelOutput(
146
+ last_hidden_state=tokens,
147
+ pooler_output=selected,
148
+ cls_embedding=cls_embedding,
149
+ mean_patch_embedding=mean_patch,
150
+ cls_mean_embedding=cls_mean,
151
+ )
152
+
153
+ def encode(self, pixel_values, feature_view=None):
154
+ """Return only the selected tile embedding."""
155
+ return self(
156
+ pixel_values=pixel_values,
157
+ feature_view=feature_view,
158
+ return_dict=True,
159
+ ).pooler_output
160
+
161
+ def get_input_embeddings(self):
162
+ """Return the ViT patch projection for Transformers integrations."""
163
+ return self.backbone.patch_embed.proj
package_model.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert a flat Mettle checkpoint into a Hugging Face safetensors artifact."""
2
+
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from collections import OrderedDict
8
+ from pathlib import Path
9
+
10
+ import torch
11
+ from safetensors.torch import save_file
12
+
13
+
14
+ EXPECTED_HEAD_KEYS = {
15
+ "head.P",
16
+ "head.Q",
17
+ "head.mix.0.bias",
18
+ "head.mix.0.weight",
19
+ "head.mix.2.bias",
20
+ "head.mix.2.weight",
21
+ "head.shift.0.bias",
22
+ "head.shift.0.weight",
23
+ "head.shift.2.bias",
24
+ "head.shift.2.weight",
25
+ }
26
+ RC2_SOURCE_SHA256 = (
27
+ "ad79736b61638a58a72c5dedd7eb21582e94c6397564ae28c1c5da0af4d8a9be"
28
+ )
29
+
30
+
31
+ def sha256(path, chunk_size=16 * 1024 * 1024):
32
+ digest = hashlib.sha256()
33
+ with open(path, "rb") as stream:
34
+ while chunk := stream.read(chunk_size):
35
+ digest.update(chunk)
36
+ return digest.hexdigest()
37
+
38
+
39
+ def convert_state_dict(source):
40
+ """Prefix backbone tensors for :class:`MettleModel`."""
41
+ head_keys = {key for key in source if key.startswith("head.")}
42
+ if head_keys != EXPECTED_HEAD_KEYS:
43
+ missing = sorted(EXPECTED_HEAD_KEYS - head_keys)
44
+ unexpected = sorted(head_keys - EXPECTED_HEAD_KEYS)
45
+ raise ValueError(
46
+ "checkpoint refinement-head keys do not match the release contract; "
47
+ f"missing={missing}, unexpected={unexpected}"
48
+ )
49
+ converted = OrderedDict()
50
+ for key in sorted(source):
51
+ target = key if key.startswith("head.") else f"backbone.{key}"
52
+ converted[target] = source[key].contiguous()
53
+ return converted
54
+
55
+
56
+ def validate_shapes(state):
57
+ p_shape = tuple(state["head.P"].shape)
58
+ q_shape = tuple(state["head.Q"].shape)
59
+ if p_shape != (16, 1536, 8) or q_shape != p_shape:
60
+ raise ValueError(
61
+ "checkpoint refinement-head shape does not match RC2: "
62
+ f"P={p_shape}, Q={q_shape}"
63
+ )
64
+ if tuple(state["head.mix.0.weight"].shape) != (256, 1536):
65
+ raise ValueError("checkpoint refinement-head hidden size is not 256")
66
+ if tuple(state["head.shift.2.weight"].shape) != (1536, 256):
67
+ raise ValueError("checkpoint refinement-head output width is not 1536")
68
+
69
+
70
+ def parse_args():
71
+ parser = argparse.ArgumentParser()
72
+ parser.add_argument("checkpoint", help="flat RC2 .pt checkpoint")
73
+ parser.add_argument(
74
+ "--output-dir",
75
+ default=str(Path(__file__).resolve().parent),
76
+ help="directory containing the Hugging Face repository files",
77
+ )
78
+ parser.add_argument(
79
+ "--expected-sha256",
80
+ default=RC2_SOURCE_SHA256,
81
+ help="refuse a source checkpoint whose SHA-256 differs",
82
+ )
83
+ parser.add_argument(
84
+ "--dry-run",
85
+ action="store_true",
86
+ help="validate and map the checkpoint without writing weights",
87
+ )
88
+ return parser.parse_args()
89
+
90
+
91
+ def main():
92
+ args = parse_args()
93
+ checkpoint = Path(args.checkpoint).resolve()
94
+ output_dir = Path(args.output_dir).resolve()
95
+ source_sha = sha256(checkpoint)
96
+ if args.expected_sha256 and source_sha != args.expected_sha256.lower():
97
+ raise SystemExit(
98
+ f"source SHA-256 mismatch: expected {args.expected_sha256}, "
99
+ f"found {source_sha}"
100
+ )
101
+
102
+ source = torch.load(checkpoint, map_location="cpu", weights_only=True)
103
+ validate_shapes(source)
104
+ converted = convert_state_dict(source)
105
+ print(
106
+ f"validated {len(source)} tensors; mapped {len(converted)} tensors; "
107
+ f"source sha256={source_sha}"
108
+ )
109
+ if args.dry_run:
110
+ return
111
+
112
+ output_dir.mkdir(parents=True, exist_ok=True)
113
+ output_path = output_dir / "model.safetensors"
114
+ manifest_path = output_dir / "weights_manifest.json"
115
+ if output_path.exists():
116
+ raise SystemExit(f"refusing to overwrite {output_path}")
117
+ if manifest_path.exists():
118
+ raise SystemExit(f"refusing to overwrite {manifest_path}")
119
+ temporary = output_dir / ".model.safetensors.incomplete"
120
+ if temporary.exists():
121
+ raise SystemExit(f"refusing to overwrite incomplete file {temporary}")
122
+ save_file(converted, str(temporary), metadata={"format": "pt"})
123
+ os.replace(temporary, output_path)
124
+ packaged_sha = sha256(output_path)
125
+ manifest = {
126
+ "format": "safetensors",
127
+ "model_file": output_path.name,
128
+ "model_sha256": packaged_sha,
129
+ "source_checkpoint_sha256": source_sha,
130
+ "tensor_count": len(converted),
131
+ }
132
+ manifest_path.write_text(
133
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n",
134
+ encoding="utf-8",
135
+ )
136
+ print(f"wrote {output_path} sha256={packaged_sha}")
137
+ print(f"wrote {manifest_path}")
138
+
139
+
140
+ if __name__ == "__main__":
141
+ main()
preprocessor_config.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "do_center_crop": false,
3
+ "do_normalize": true,
4
+ "do_rescale": true,
5
+ "do_resize": true,
6
+ "image_mean": [
7
+ 0.707223,
8
+ 0.578729,
9
+ 0.703617
10
+ ],
11
+ "image_processor_type": "ViTImageProcessor",
12
+ "image_std": [
13
+ 0.211883,
14
+ 0.230117,
15
+ 0.177517
16
+ ],
17
+ "resample": 3,
18
+ "rescale_factor": 0.00392156862745098,
19
+ "size": {
20
+ "height": 224,
21
+ "width": 224
22
+ }
23
+ }
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ torch>=2.2
2
+ timm>=1.0.15
3
+ transformers>=4.48
4
+ safetensors>=0.4
run_test.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke-test a packaged local or downloaded Mettle-MX repository."""
2
+
3
+ import argparse
4
+
5
+ import torch
6
+ from PIL import Image
7
+ from transformers import AutoImageProcessor, AutoModel
8
+
9
+
10
+ def parse_args():
11
+ parser = argparse.ArgumentParser()
12
+ parser.add_argument("model", help="local directory or Hugging Face repository ID")
13
+ parser.add_argument("--image", help="optional RGB image to encode")
14
+ parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
15
+ return parser.parse_args()
16
+
17
+
18
+ def main():
19
+ args = parse_args()
20
+ processor = AutoImageProcessor.from_pretrained(args.model)
21
+ model = AutoModel.from_pretrained(
22
+ args.model,
23
+ trust_remote_code=True,
24
+ ).to(args.device).eval()
25
+ if args.image:
26
+ image = Image.open(args.image).convert("RGB")
27
+ else:
28
+ image = Image.new("RGB", (224, 224), color=(128, 96, 128))
29
+ inputs = processor(images=image, return_tensors="pt")
30
+ pixel_values = inputs["pixel_values"].to(args.device)
31
+ with torch.inference_mode():
32
+ cls = model.encode(pixel_values, feature_view="cls")
33
+ cls_mean = model.encode(pixel_values, feature_view="cls_mean")
34
+ if tuple(cls.shape) != (1, 1536):
35
+ raise RuntimeError(f"unexpected CLS shape: {tuple(cls.shape)}")
36
+ if tuple(cls_mean.shape) != (1, 3072):
37
+ raise RuntimeError(f"unexpected CLS+mean shape: {tuple(cls_mean.shape)}")
38
+ if not torch.equal(cls, cls_mean[:, :1536]):
39
+ raise RuntimeError("CLS differs between the two feature views")
40
+ print(f"PASS cls={tuple(cls.shape)} cls_mean={tuple(cls_mean.shape)}")
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
verify_against_flat.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Numerically compare the release model with a flat Mettle checkpoint."""
2
+
3
+ import argparse
4
+ import gc
5
+ from pathlib import Path
6
+
7
+ import timm
8
+ import torch
9
+
10
+ from configuration_mettle import MettleConfig
11
+ from modeling_mettle import MettleModel, MettleRefineHead
12
+ from package_model import convert_state_dict, validate_shapes
13
+
14
+
15
+ def parse_args():
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("checkpoint", help="flat Mettle checkpoint")
18
+ parser.add_argument(
19
+ "--device",
20
+ default="cuda" if torch.cuda.is_available() else "cpu",
21
+ )
22
+ parser.add_argument("--atol", type=float, default=1e-5)
23
+ return parser.parse_args()
24
+
25
+
26
+ def load_config():
27
+ return MettleConfig.from_json_file(
28
+ str(Path(__file__).with_name("config.json"))
29
+ )
30
+
31
+
32
+ def reference_outputs(config, state, pixel_values):
33
+ backbone = timm.create_model(
34
+ config.backbone_name,
35
+ pretrained=False,
36
+ num_classes=0,
37
+ init_values=1e-5,
38
+ dynamic_img_size=False,
39
+ img_size=config.image_size,
40
+ )
41
+ backbone_state = {
42
+ key: value for key, value in state.items()
43
+ if not key.startswith("head.")
44
+ }
45
+ backbone.load_state_dict(backbone_state, strict=True)
46
+ head = MettleRefineHead(
47
+ dim=config.hidden_size,
48
+ num_atoms=config.head_num_atoms,
49
+ rank=config.head_rank,
50
+ hidden_size=config.head_hidden_size,
51
+ )
52
+ head_state = {
53
+ key.removeprefix("head."): value for key, value in state.items()
54
+ if key.startswith("head.")
55
+ }
56
+ head.load_state_dict(head_state, strict=True)
57
+ backbone.to(pixel_values.device).eval()
58
+ head.to(pixel_values.device).eval()
59
+ with torch.inference_mode():
60
+ tokens = backbone.forward_features(pixel_values)
61
+ cls = head(tokens[:, 0].to(torch.float32))
62
+ mean_patch = tokens[:, int(backbone.num_prefix_tokens):].float().mean(1)
63
+ cls_mean = torch.cat((cls, mean_patch), dim=-1)
64
+ return cls.cpu(), cls_mean.cpu()
65
+
66
+
67
+ def release_outputs(config, state, pixel_values):
68
+ model = MettleModel(config)
69
+ model.load_state_dict(convert_state_dict(state), strict=True)
70
+ model.to(pixel_values.device).eval()
71
+ with torch.inference_mode():
72
+ cls = model.encode(pixel_values, "cls")
73
+ cls_mean = model.encode(pixel_values, "cls_mean")
74
+ return cls.cpu(), cls_mean.cpu()
75
+
76
+
77
+ def main():
78
+ args = parse_args()
79
+ config = load_config()
80
+ state = torch.load(args.checkpoint, map_location="cpu", weights_only=True)
81
+ validate_shapes(state)
82
+ generator = torch.Generator().manual_seed(20260730)
83
+ pixel_values = torch.randn(
84
+ 1,
85
+ 3,
86
+ config.image_size,
87
+ config.image_size,
88
+ generator=generator,
89
+ ).to(args.device)
90
+
91
+ reference_cls, reference_cls_mean = reference_outputs(
92
+ config,
93
+ state,
94
+ pixel_values,
95
+ )
96
+ if args.device.startswith("cuda"):
97
+ torch.cuda.empty_cache()
98
+ gc.collect()
99
+ release_cls, release_cls_mean = release_outputs(
100
+ config,
101
+ state,
102
+ pixel_values,
103
+ )
104
+
105
+ cls_delta = (reference_cls - release_cls).abs().max().item()
106
+ cls_mean_delta = (
107
+ reference_cls_mean - release_cls_mean
108
+ ).abs().max().item()
109
+ print(f"CLS max absolute delta: {cls_delta:.3e}")
110
+ print(f"CLS+mean max absolute delta: {cls_mean_delta:.3e}")
111
+ if cls_delta > args.atol or cls_mean_delta > args.atol:
112
+ raise SystemExit(
113
+ "FAILED: release outputs differ from the flat-checkpoint reference"
114
+ )
115
+ print("PASS: both feature views reproduce the flat-checkpoint reference")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()