dp-vit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dp_vit/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ from .checkpoint import load_checkpoint, save_checkpoint
2
+ from .config import DPViTConfig, ICSCalibration, ICSConfig, PruningConfig
3
+ from .ics import (
4
+ compute_gradient_magnitude,
5
+ compute_ics,
6
+ compute_local_variance,
7
+ compute_patch_size_map_for_image,
8
+ determine_patch_size_map,
9
+ fit_ics_calibration,
10
+ )
11
+ from .inference import predict_tensor
12
+ from .model import DPViT
13
+ from .patching import AdaptivePatchEmbedding, adaptive_collate_fn
14
+ from .pruning import prune_mlp_by_magnitude
15
+
16
+ __all__ = [
17
+ "AdaptivePatchEmbedding",
18
+ "DPViT",
19
+ "DPViTConfig",
20
+ "ICSCalibration",
21
+ "ICSConfig",
22
+ "PruningConfig",
23
+ "adaptive_collate_fn",
24
+ "compute_gradient_magnitude",
25
+ "compute_ics",
26
+ "compute_local_variance",
27
+ "compute_patch_size_map_for_image",
28
+ "determine_patch_size_map",
29
+ "fit_ics_calibration",
30
+ "load_checkpoint",
31
+ "predict_tensor",
32
+ "prune_mlp_by_magnitude",
33
+ "save_checkpoint",
34
+ ]
dp_vit/checkpoint.py ADDED
@@ -0,0 +1,147 @@
1
+ """Checkpoint saving and loading for DP-ViT."""
2
+
3
+ from dataclasses import asdict
4
+ from pathlib import Path
5
+ from typing import Any, cast
6
+
7
+ import torch
8
+ from torch import nn
9
+
10
+ from .config import DPViTConfig, ICSCalibration
11
+ from .model import DPViT
12
+
13
+ CHECKPOINT_FORMAT_VERSION = 1
14
+
15
+ def _get_mlp_widths(model: DPViT) -> list[int]:
16
+ """Return the hidden width of every encoder MLP."""
17
+ return [
18
+ int(getattr(getattr(layer, "linear1"), "out_features"))
19
+ for layer in model.encoder.layers
20
+ ]
21
+
22
+ def _resize_mlp_layer(
23
+ layer: nn.TransformerEncoderLayer,
24
+ hidden_width: int,
25
+ ) -> None:
26
+ """Create MLP layers with a target width before leading checkpoint weights"""
27
+ if hidden_width <= 0:
28
+ raise ValueError("Each MLP hidden width must be greater than zero.")
29
+
30
+ old_linear1 = layer.linear1
31
+ old_linear2 = layer.linear2
32
+
33
+ new_linear1 = nn.Linear(
34
+ in_features=old_linear1.in_features,
35
+ out_features=hidden_width,
36
+ bias=old_linear1.bias is not None,
37
+ device=old_linear1.weight.device,
38
+ dtype=old_linear1.weight.dtype,
39
+ )
40
+ new_linear2 = nn.Linear(
41
+ in_features=hidden_width,
42
+ out_features=old_linear2.out_features,
43
+ bias=old_linear2.bias is not None,
44
+ device=old_linear2.weight.device,
45
+ dtype=old_linear2.weight.dtype,
46
+ )
47
+
48
+ layer.linear1 = new_linear1
49
+ layer.linear2 = new_linear2
50
+
51
+ def save_checkpoint(
52
+ path: str | Path,
53
+ model: DPViT,
54
+ ics_calibration: ICSCalibration,
55
+ class_names: list[str] | None = None,
56
+ pruning_report: dict[str, Any] | None = None,
57
+ ) -> Path:
58
+ """Save a DP-ViT checkpoint and the metadata needed to rebuild it."""
59
+ if not isinstance(model, DPViT):
60
+ raise TypeError("model must be an instance of DPViT")
61
+
62
+ if not isinstance(ics_calibration, ICSCalibration):
63
+ raise TypeError("ics_calibration must be an ICSCalibration instance.")
64
+
65
+ if class_names is not None:
66
+ if len(class_names) != model.config.num_classes:
67
+ raise ValueError(
68
+ "The number of class names must match num_classes."
69
+ )
70
+
71
+ checkpoint_path = Path(path).expanduser()
72
+ checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
73
+
74
+ payload = {
75
+ "format_version": CHECKPOINT_FORMAT_VERSION,
76
+ "model_config": asdict(model.config),
77
+ "ics_calibration": asdict(ics_calibration),
78
+ "class_names": class_names,
79
+ "mlp_widths": _get_mlp_widths(model),
80
+ "pruning_report": pruning_report,
81
+ "model_state_dict": {
82
+ name: value.detach().cpu()
83
+ for name, value in model.state_dict().items()
84
+ },
85
+ }
86
+
87
+ temporary_path = checkpoint_path.with_suffix(
88
+ checkpoint_path.suffix + ".tmp"
89
+ )
90
+ torch.save(payload, temporary_path)
91
+ temporary_path.replace(checkpoint_path)
92
+
93
+ return checkpoint_path
94
+
95
+ def load_checkpoint(
96
+ path: str | Path,
97
+ map_location: str | torch.device = "cpu",
98
+ ) -> tuple[DPViT, ICSCalibration, dict[str, Any]]:
99
+ """
100
+ Load a DP-ViT checkpoint and reconstruct its architecture.
101
+
102
+ Returns:
103
+ model: The reconstructured DP-ViT model.
104
+ ics_calibration: The ICS parameters saved with the model.
105
+ metadata: Class names, MLP widths, and pruning report.
106
+ """
107
+ checkpoint_path = Path(path).expanduser()
108
+
109
+ if not checkpoint_path.is_file():
110
+ raise FileNotFoundError(f"Checkpoint file does not exist: {checkpoint_path}")
111
+
112
+ payload = torch.load(
113
+ checkpoint_path,
114
+ map_location=map_location,
115
+ weights_only=True,
116
+ )
117
+
118
+ if payload.get("format_version") != CHECKPOINT_FORMAT_VERSION:
119
+ raise ValueError("Unsupported or missing DP-ViT checkpoint format version.")
120
+
121
+ config = DPViTConfig(**payload["model_config"])
122
+ calibration = ICSCalibration(**payload["ics_calibration"])
123
+
124
+ model = DPViT(config)
125
+
126
+ mlp_widths = payload["mlp_widths"]
127
+ encoder_layers = list(model.encoder.layers)
128
+
129
+ if len(mlp_widths) != len(encoder_layers):
130
+ raise ValueError("Checkpoint MLP widths do not match the encoder depth.")
131
+
132
+ for layer, hidden_width in zip(encoder_layers, mlp_widths):
133
+ _resize_mlp_layer(
134
+ cast(nn.TransformerEncoderLayer, layer),
135
+ int(hidden_width),
136
+ )
137
+
138
+ model.load_state_dict(payload["model_state_dict"])
139
+ model.to(map_location)
140
+
141
+ metadata = {
142
+ "class_names": payload.get("class_names"),
143
+ "mlp_widths": mlp_widths,
144
+ "pruning_report": payload.get("pruning_report"),
145
+ }
146
+
147
+ return model, calibration, metadata
dp_vit/config.py ADDED
@@ -0,0 +1,116 @@
1
+ """Configuration objects for the DP-ViT Library."""
2
+
3
+ from dataclasses import dataclass
4
+ import math
5
+
6
+ @dataclass(frozen=True)
7
+ class DPViTConfig:
8
+ """Architecture Dynamic Patch Vision Transformer"""
9
+
10
+ num_classes: int
11
+
12
+ image_size: int = 224
13
+ region_size: int = 32
14
+ patch_sizes: tuple[int, int, int] = (8, 16, 32)
15
+
16
+ embed_dim: int = 384
17
+ num_layers: int = 12
18
+ num_heads: int = 6
19
+ mlp_ratio: float = 4.0
20
+ dropout: float = 0.1
21
+
22
+ def __post_init__(self) -> None:
23
+ if self.num_classes <= 0:
24
+ raise ValueError("num_classes must be bigger than 0")
25
+
26
+ if self.image_size <= 0:
27
+ raise ValueError("image_size must be bigger than 0")
28
+
29
+ if self.region_size <= 0:
30
+ raise ValueError("region_size must be bigger than 0")
31
+
32
+ if self.image_size % self.region_size !=0:
33
+ raise ValueError("image_size must be divisible by region_size")
34
+
35
+ if self.patch_sizes != (8, 16, 32):
36
+ raise ValueError("patch_size must be (8, 16, 32)")
37
+
38
+ if any(self.region_size % size != 0 for size in self.patch_sizes):
39
+ raise ValueError("region_size must be divisible by all patch sizes")
40
+
41
+ if self.embed_dim <= 0 or self.num_layers <= 0 or self.num_heads <= 0:
42
+ raise ValueError("embed_dim, num_layers, and num_heads must be bigger than 0")
43
+
44
+ if self.embed_dim % self.num_heads != 0:
45
+ raise ValueError("embed_dim must be divisible by num_heads")
46
+
47
+ if not math.isfinite(self.mlp_ratio) or self.mlp_ratio <= 0:
48
+ raise ValueError("mlp_ratio must be positive number")
49
+
50
+ if not math.isfinite(self.dropout) or not 0 <= self.dropout < 1:
51
+ raise ValueError("dropout must be in range [0, 1].")
52
+
53
+ @property
54
+ def max_position_cell(self) -> int:
55
+ """Maximum number of position cells."""
56
+ smallest_patch = min(self.patch_sizes)
57
+ return (self.image_size // smallest_patch) ** 2
58
+
59
+ @dataclass(frozen=True)
60
+ class ICSConfig:
61
+ "Config percentile for ICS module"
62
+
63
+ low_percentile: float = 30.0
64
+ high_percentile: float = 70.0
65
+
66
+ def __post_init__(self) -> None:
67
+ if not 0 <= self.low_percentile <= 100:
68
+ raise ValueError("low_percentile must be in range [0, 100]")
69
+
70
+ if not 0 <= self.high_percentile <= 100:
71
+ raise ValueError("high_percentile must be in range [0, 100]")
72
+
73
+ if self.low_percentile >= self.high_percentile:
74
+ raise ValueError("low_percentile must be smaller than high_percentile.")
75
+
76
+ if ( self.low_percentile != 30.0 or self.high_percentile != 70.0):
77
+ raise ValueError("The current ICSCalibration supports only the 30th and 70th percentile.")
78
+
79
+ @dataclass(frozen=True)
80
+ class ICSCalibration:
81
+ """Calibration ICS value used for mapping patch."""
82
+
83
+ alpha: float
84
+ beta: float
85
+ p30: float
86
+ p70: float
87
+
88
+ def __post_init__ (self) -> None:
89
+ for name, value in (
90
+ ("alpha", self.alpha),
91
+ ("beta", self.beta),
92
+ ("p30", self.p30),
93
+ ("p70", self.p70),
94
+ ):
95
+ if not math.isfinite(value):
96
+ raise ValueError(f"{name} must be finite number"
97
+ )
98
+
99
+ if self.alpha <= 0 or self.beta <= 0:
100
+ raise ValueError("alpha and beta must be greater than 0.")
101
+
102
+ if self.p30 > self.p70:
103
+ raise ValueError("p30 must be smaller than p70.")
104
+
105
+ @dataclass(frozen=True)
106
+ class PruningConfig:
107
+ """Configuration for neuron MLP structured magnitude-based pruning."""
108
+
109
+ target_ratio: float = 0.2
110
+
111
+ def __post_init__(self) -> None:
112
+ if not math.isfinite(self.target_ratio):
113
+ raise ValueError("target_ratio must be finite number")
114
+
115
+ if not 0 < self.target_ratio < 1:
116
+ raise ValueError("target_ratio must be greater than 0 and smaller than 1.")
dp_vit/ics.py ADDED
@@ -0,0 +1,215 @@
1
+ """INformation Complexity Score (ICS) calculations for DP-ViT."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ import numpy as np
6
+ from scipy.ndimage import sobel
7
+
8
+ from .config import ICSCalibration, ICSConfig
9
+
10
+ def _to_grayscale(image: np.ndarray) -> np.ndarray:
11
+ """Convert a grayscale or RGB image to a 2D float64 intensity array."""
12
+ image = np.asarray(image)
13
+
14
+ if image.ndim == 2:
15
+ gray = image.astype(np.float64)
16
+ elif image.ndim == 3 and image.shape[2] == 3:
17
+ image = image.astype(np.float64)
18
+ gray = (
19
+ 0.299 * image[..., 0]
20
+ + 0.587 * image[..., 1]
21
+ + 0.114 * image[..., 2]
22
+ )
23
+ else:
24
+ raise ValueError("image must have shape (height, width) or (height, width, 3).")
25
+
26
+ if not np.isfinite(gray).all():
27
+ raise ValueError("image must contain only finite values.")
28
+
29
+ return gray
30
+
31
+ def _make_region_blocks(
32
+ image: np.ndarray,
33
+ region_size: int,
34
+ ) -> np.ndarray:
35
+ """Split a 2D image into non-overlapping square regions."""
36
+ if isinstance(region_size, bool) or not isinstance(region_size, int):
37
+ raise TypeError("region_size must be an integer.")
38
+
39
+ if region_size <= 0:
40
+ raise ValueError("region_size must be greater than zero.")
41
+
42
+ height, width = image.shape
43
+
44
+ if height % region_size != 0 or width % region_size != 0:
45
+ raise ValueError("Image height and width must be divisible by region_size.")
46
+
47
+ rows = height // region_size
48
+ cols = width // region_size
49
+
50
+ blocks = image.reshape(
51
+ rows, region_size, cols, region_size
52
+ )
53
+ blocks = blocks.transpose(0, 2, 1, 3)
54
+
55
+ return blocks.reshape(rows, cols, region_size * region_size)
56
+
57
+ def compute_local_variance(
58
+ image: np.ndarray,
59
+ region_size: int = 32,
60
+ ) -> np.ndarray:
61
+ """
62
+ Compute the population variance of grayscale intensity in each region.
63
+
64
+ The variance for each region is
65
+ Var(R) = mean((pixel - region_mean) ** 2)
66
+ """
67
+ gray = _to_grayscale(image)
68
+ blocks = _make_region_blocks(gray, region_size)
69
+
70
+ region_means = blocks.mean(axis=-1, keepdims=True)
71
+ variance_map = np.mean((blocks - region_means) ** 2, axis=-1)
72
+
73
+ return variance_map
74
+
75
+ def compute_gradient_magnitude(
76
+ image: np.ndarray,
77
+ region_size: int = 32,
78
+ ) -> np.ndarray:
79
+ """
80
+ Compute the mean Sobel gradient magnitude in each region.
81
+
82
+ Sobel gradients are calculated on full image before region averaging.
83
+ """
84
+ gray = _to_grayscale(image)
85
+
86
+ gradient_x = sobel(gray, axis=1)
87
+ gradient_y = sobel(gray, axis=0)
88
+ magnitude = np.sqrt(gradient_x ** 2 + gradient_y ** 2)
89
+
90
+ blocks = _make_region_blocks(magnitude, region_size)
91
+ return blocks.mean(axis=-1)
92
+
93
+ def compute_ics(
94
+ variance_map: np.ndarray,
95
+ gradient_map: np.ndarray,
96
+ alpha: float,
97
+ beta: float,
98
+ ) -> np.ndarray:
99
+ """Combine local variance and gradient magnitude into ICS map."""
100
+ variance_map = np.asarray(variance_map, dtype=np.float64)
101
+ gradient_map = np.asarray(gradient_map, dtype=np.float64)
102
+
103
+ if variance_map.shape != gradient_map.shape:
104
+ raise ValueError("variance_map and gradient_map must have the same shape.")
105
+
106
+ if not np.isfinite(alpha) or alpha <= 0:
107
+ raise ValueError("alpha must be a finite positive number.")
108
+
109
+ if not np.isfinite(beta) or beta <= 0:
110
+ raise ValueError("beta must be a finite positive number.")
111
+
112
+ return alpha * variance_map + beta * gradient_map
113
+
114
+ def fit_ics_calibration(
115
+ calibration_images: Sequence[np.ndarray],
116
+ region_size: int = 32,
117
+ config: ICSConfig | None = None,
118
+ ) -> ICSCalibration:
119
+ """
120
+ Fit ICS weights and global percentile thresholds using calibration images.
121
+
122
+ All images must use the same intensity range and preprocessing convention.
123
+ """
124
+ if not calibration_images:
125
+ raise ValueError("calibration_images must not be empty.")
126
+
127
+ if config is None:
128
+ config = ICSConfig()
129
+
130
+ all_variances = []
131
+ all_gradients = []
132
+
133
+ for image in calibration_images:
134
+ all_variances.append(
135
+ compute_local_variance(image, region_size).ravel()
136
+ )
137
+ all_gradients.append(
138
+ compute_gradient_magnitude(image, region_size).ravel()
139
+ )
140
+
141
+ all_variances = np.concatenate(all_variances)
142
+ all_gradients = np.concatenate(all_gradients)
143
+
144
+ epsilon = 1e-8
145
+ alpha = 1.0 / (all_variances.std() + epsilon)
146
+ beta = 1.0 / (all_gradients.std() + epsilon)
147
+
148
+ all_ics = alpha * all_variances + beta * all_gradients
149
+
150
+ p30 = float(np.percentile(all_ics, config.low_percentile))
151
+ p70 = float(np.percentile(all_ics, config.high_percentile))
152
+
153
+ return ICSCalibration(
154
+ alpha=float(alpha),
155
+ beta=float(beta),
156
+ p30=p30,
157
+ p70=p70,
158
+ )
159
+
160
+ def determine_patch_size_map(
161
+ ics_map: np.ndarray,
162
+ calibration: ICSCalibration,
163
+ patch_sizes: tuple[int, int, int] = (8, 16, 32),
164
+ ) -> np.ndarray:
165
+ """
166
+ Map each ICS region to a patch size.
167
+
168
+ The patch_sizes tuple is ordewred as (small, medium, large).
169
+ """
170
+ if len(patch_sizes) != 3:
171
+ raise ValueError("patch_sizes must contain exactly three values.")
172
+
173
+ small_patch, medium_patch, large_patch = patch_sizes
174
+
175
+ if not small_patch < medium_patch < large_patch:
176
+ raise ValueError("patch_sizes must be ordered from smallest to largest.")
177
+
178
+ size_map = np.full(ics_map.shape, large_patch, dtype=np.int32)
179
+
180
+ medium_mask = (
181
+ (ics_map >= calibration.p30)
182
+ & (ics_map < calibration.p70)
183
+ )
184
+
185
+ small_mask = ics_map >= calibration.p70
186
+
187
+ size_map[medium_mask] = medium_patch
188
+ size_map[small_mask] = small_patch
189
+
190
+ return size_map
191
+
192
+ def compute_patch_size_map_for_image(
193
+ image: np.ndarray,
194
+ calibration: ICSCalibration,
195
+ region_size: int = 32,
196
+ patch_sizes: tuple[int, int, int] = (8, 16, 32),
197
+ ) -> np.ndarray:
198
+ """
199
+ Compute an iamge's ICS map and convert it to an adaptive patch map.
200
+ """
201
+ variance_map = compute_local_variance(image, region_size)
202
+ gradient_map = compute_gradient_magnitude(image, region_size)
203
+
204
+ ics_map = compute_ics(
205
+ variance_map,
206
+ gradient_map,
207
+ alpha=calibration.alpha,
208
+ beta=calibration.beta,
209
+ )
210
+
211
+ return determine_patch_size_map(
212
+ ics_map,
213
+ calibration=calibration,
214
+ patch_sizes=patch_sizes
215
+ )
dp_vit/inference.py ADDED
@@ -0,0 +1,132 @@
1
+ """Single-image inference utilities for DP-ViT."""
2
+
3
+ from collections.abc import Callable, Sequence
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ import torch
8
+ from torch import Tensor
9
+
10
+ from .config import ICSCalibration
11
+ from .ics import compute_patch_size_map_for_image
12
+ from .model import DPViT
13
+ from .patching import adaptive_collate_fn
14
+
15
+ def predict_tensor(
16
+ model: DPViT,
17
+ image_before_normalization: Tensor,
18
+ ics_calibration: ICSCalibration,
19
+ class_names: Sequence[str] | None = None,
20
+ normalize_fn : Callable[[Tensor], Tensor] | None = None,
21
+ ) -> dict[str, Any]:
22
+ """
23
+ Predict the class of one preprocessed image tensor.
24
+
25
+ The input must be an RGB tensor in [0, 1], before channel normalization.
26
+ Resizing and other user-selected preprocessing must be applied beforehand.
27
+ """
28
+ if not isinstance(model, DPViT):
29
+ raise TypeError("model must be an instance of DPViT.")
30
+
31
+ if not isinstance(image_before_normalization, Tensor):
32
+ raise TypeError("image_before_normalization must be a torch.Tensor.")
33
+
34
+ if image_before_normalization.ndim != 3:
35
+ raise ValueError("The input image must have shape (channels, height, width).")
36
+
37
+ channels, height, width = image_before_normalization.shape
38
+
39
+ if channels != 3:
40
+ raise ValueError("DP-ViT currently expects an RGB image with 3 channels.")
41
+
42
+ if (height, width) != (model.config.image_size, model.config.image_size):
43
+ raise ValueError(
44
+ f"Expected an image of size {model.config.image_size}x"
45
+ f"{model.config.image_size}, got {height}x{width}."
46
+ )
47
+
48
+ if not torch.isfinite(image_before_normalization).all():
49
+ raise ValueError("The input image must contain only finite values.")
50
+
51
+ if (
52
+ image_before_normalization.min() <0
53
+ or image_before_normalization.max() > 1
54
+ ):
55
+ raise ValueError(
56
+ "ICS input must be in [0, 1]. Apply channel normalization"
57
+ "after ICS patch-map generation."
58
+ )
59
+
60
+ if not isinstance(ics_calibration, ICSCalibration):
61
+ raise TypeError("ics_calibration must be an ICSCalibration instance.")
62
+
63
+ if class_names is not None and len(class_names) != model.config.num_classes:
64
+ raise ValueError("class_names length must match model.num_classes.")
65
+
66
+ image = image_before_normalization.detach().to(
67
+ device="cpu",
68
+ dtype=torch.float32,
69
+ )
70
+
71
+ image_np = image.permute(1, 2, 0).numpy()
72
+
73
+ patch_size_map = compute_patch_size_map_for_image(
74
+ image=image_np,
75
+ calibration=ics_calibration,
76
+ region_size=model.config.region_size,
77
+ patch_sizes=model.config.patch_sizes,
78
+ )
79
+
80
+ if normalize_fn is None:
81
+ model_image = image
82
+ else:
83
+ model_image = normalize_fn(image)
84
+
85
+ if not isinstance(model_image, Tensor):
86
+ raise TypeError("normalize_fn must return a torch.Tensor.")
87
+
88
+ if model_image.shape != image.shape:
89
+ raise ValueError("normalize_fn must preserve the input image shape.")
90
+
91
+ batch = adaptive_collate_fn(
92
+ [(model_image, patch_size_map, 0)],
93
+ region_size=model.config.region_size,
94
+ patch_sizes=model.config.patch_sizes,
95
+ )
96
+
97
+ device = next(model.parameters()).device
98
+ batch = {
99
+ key: value.to(device) if isinstance(value, Tensor) else value
100
+ for key, value in batch.items()
101
+ }
102
+
103
+ was_training = model.training
104
+ model.eval()
105
+
106
+ try:
107
+ with torch.inference_mode():
108
+ logits = model(batch)
109
+ probabilities = torch.softmax(logits, dim=1)[0]
110
+ predicted_index = int(probabilities.argmax())
111
+ finally:
112
+ model.train(was_training)
113
+
114
+ patch_counts = {
115
+ int(size): int(np.count_nonzero(patch_size_map == size))
116
+ for size in model.config.patch_sizes
117
+ }
118
+
119
+ predicted_name = (
120
+ class_names[predicted_index]
121
+ if class_names is not None
122
+ else None
123
+ )
124
+
125
+ return {
126
+ "class_index": predicted_index,
127
+ "class_name": predicted_name,
128
+ "confidence": float(probabilities[predicted_index].cpu()),
129
+ "probabilities": probabilities.cpu().tolist(),
130
+ "patch_size_map": patch_size_map.tolist(),
131
+ "patch_counts": patch_counts,
132
+ }