Feature Extraction
Transformers
Safetensors
mettle
computational-pathology
histopathology
foundation-model
scanner-robustness
custom_code
Instructions to use slideflow-labs/Mettle with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use slideflow-labs/Mettle with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("feature-extraction", model="slideflow-labs/Mettle", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("slideflow-labs/Mettle", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Add Mettle RC2 weights, loader, and model card
Browse files- README.md +145 -0
- USAGE.txt +28 -0
- __init__.py +6 -0
- config.json +20 -0
- configuration_mettle.py +38 -0
- model.safetensors +3 -0
- modeling_mettle.py +163 -0
- package_model.py +141 -0
- preprocessor_config.json +23 -0
- requirements.txt +4 -0
- run_test.py +44 -0
- verify_against_flat.py +119 -0
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()
|