easymodd 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.
easymodd/__init__.py ADDED
@@ -0,0 +1,85 @@
1
+ """EasyMoDD: configurable, training-first numerical machine learning tools."""
2
+
3
+ from .config import EasyMoDDConfig, load_config
4
+ from .tensor import Device, array, backend_of, get_backend, matmul, ones, randn, seed, zeros
5
+ from .nn import Dropout, GELU, Linear, Module, Parameter, ReLU, Sequential, Sigmoid, Tanh
6
+ from .optim import Adam, AdamW, SGD
7
+ from .schedulers import CosineScheduler, WarmupScheduler
8
+ from .metrics import accuracy, mean_absolute_error, perplexity
9
+ from .callbacks import EarlyStopping, MetricsLogger
10
+ from .serialization import load_state, save_state
11
+ from .training import Trainer, TrainingHistory
12
+ from .data import ArrayDataset, BatchLoader, Dataset, Subset, TextDataset, collate_batch, split_dataset
13
+ from .losses import CrossEntropyLoss, HuberLoss, L1Loss, MSELoss
14
+ from .checkpoints import CheckpointManager
15
+ from .experiments import Experiment
16
+ from .registry import ModelRegistry, models
17
+ from .secret_store import SecretStore
18
+ from .transforms import Compose, Flatten, MapTransform, Normalize, Standardize, ToFloat32
19
+ from .visualization import summarize_history, summarize_model
20
+ from .models import MLP, TextTransformerConfig, VisionTransformerConfig, build_mlp
21
+
22
+ __all__ = [
23
+ "Device",
24
+ "ArrayDataset",
25
+ "BatchLoader",
26
+ "Adam",
27
+ "AdamW",
28
+ "CheckpointManager",
29
+ "Compose",
30
+ "CrossEntropyLoss",
31
+ "CosineScheduler",
32
+ "Dataset",
33
+ "Dropout",
34
+ "EarlyStopping",
35
+ "EasyMoDDConfig",
36
+ "Experiment",
37
+ "Flatten",
38
+ "GELU",
39
+ "HuberLoss",
40
+ "L1Loss",
41
+ "Linear",
42
+ "MSELoss",
43
+ "MapTransform",
44
+ "ModelRegistry",
45
+ "Normalize",
46
+ "MetricsLogger",
47
+ "Module",
48
+ "Parameter",
49
+ "ReLU",
50
+ "Sequential",
51
+ "SGD",
52
+ "SecretStore",
53
+ "Sigmoid",
54
+ "Standardize",
55
+ "Tanh",
56
+ "ToFloat32",
57
+ "WarmupScheduler",
58
+ "Trainer",
59
+ "TrainingHistory",
60
+ "TextDataset",
61
+ "Subset",
62
+ "array",
63
+ "accuracy",
64
+ "backend_of",
65
+ "MLP",
66
+ "TextTransformerConfig",
67
+ "VisionTransformerConfig",
68
+ "build_mlp",
69
+ "get_backend",
70
+ "load_config",
71
+ "load_state",
72
+ "matmul",
73
+ "mean_absolute_error",
74
+ "perplexity",
75
+ "collate_batch",
76
+ "models",
77
+ "ones",
78
+ "randn",
79
+ "seed",
80
+ "save_state",
81
+ "zeros",
82
+ "split_dataset",
83
+ "summarize_history",
84
+ "summarize_model",
85
+ ]
easymodd/callbacks.py ADDED
@@ -0,0 +1,40 @@
1
+ """Reusable training callbacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+
9
+ class MetricsLogger:
10
+ """Append one JSON object per training event."""
11
+
12
+ def __init__(self, path: str | Path):
13
+ self.path = Path(path)
14
+ self.path.parent.mkdir(parents=True, exist_ok=True)
15
+
16
+ def __call__(self, *, step: int, metrics: dict[str, float]) -> None:
17
+ with self.path.open("a", encoding="utf-8") as handle:
18
+ handle.write(json.dumps({"step": step, **metrics}) + "\n")
19
+
20
+
21
+ class EarlyStopping:
22
+ def __init__(self, metric: str = "loss", patience: int = 10, minimum_delta: float = 0.0):
23
+ if patience < 1:
24
+ raise ValueError("patience must be at least 1")
25
+ self.metric = metric
26
+ self.patience = patience
27
+ self.minimum_delta = minimum_delta
28
+ self.best = float("inf")
29
+ self.bad_steps = 0
30
+
31
+ def __call__(self, *, step: int, metrics: dict[str, float]) -> bool:
32
+ value = metrics.get(self.metric)
33
+ if value is None:
34
+ return False
35
+ if value < self.best - self.minimum_delta:
36
+ self.best = value
37
+ self.bad_steps = 0
38
+ else:
39
+ self.bad_steps += 1
40
+ return self.bad_steps >= self.patience
@@ -0,0 +1,59 @@
1
+ """Config-controlled checkpoint management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ import zipfile
8
+ from .serialization import save_state
9
+
10
+
11
+ class CheckpointManager:
12
+ """Checkpoint policy that creates no files when disabled."""
13
+
14
+ def __init__(self, directory: str | Path, enabled: bool = True,
15
+ keep_recent: int = 2, compress_old: bool = True):
16
+ if keep_recent < 1:
17
+ raise ValueError("keep_recent must be at least 1")
18
+ self.directory = Path(directory)
19
+ self.enabled = bool(enabled)
20
+ self.keep_recent = keep_recent
21
+ self.compress_old = bool(compress_old)
22
+
23
+ def save(self, state: dict, step: int) -> Path | None:
24
+ if not self.enabled:
25
+ return None
26
+ self.directory.mkdir(parents=True, exist_ok=True)
27
+ path = self.directory / f"step_{step:07d}.json"
28
+ path.write_text(json.dumps(state, indent=2), encoding="utf-8")
29
+ self._compress_old()
30
+ return path
31
+
32
+ def save_training_state(self, step: int, *, model, optimizer=None,
33
+ scheduler=None, config=None, metrics=None) -> Path | None:
34
+ if not self.enabled:
35
+ return None
36
+ self.directory.mkdir(parents=True, exist_ok=True)
37
+ path = self.directory / f"step_{step:07d}.pkl"
38
+ save_state(path, model=model, optimizer=optimizer, scheduler=scheduler,
39
+ config=config, metrics=metrics)
40
+ self._compress_artifacts()
41
+ return path
42
+
43
+ def _compress_old(self) -> None:
44
+ files = sorted(self.directory.glob("step_*.json"), key=lambda item: item.stat().st_mtime)
45
+ for path in files[:-self.keep_recent]:
46
+ archive = path.with_suffix(".zip")
47
+ if self.compress_old and not archive.exists():
48
+ with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as handle:
49
+ handle.write(path, arcname=path.name)
50
+ path.unlink()
51
+
52
+ def _compress_artifacts(self) -> None:
53
+ files = sorted(self.directory.glob("step_*.pkl"), key=lambda item: item.stat().st_mtime)
54
+ for path in files[:-self.keep_recent]:
55
+ archive = path.with_suffix(".zip")
56
+ if self.compress_old and not archive.exists():
57
+ with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as handle:
58
+ handle.write(path, arcname=path.name)
59
+ path.unlink()
easymodd/cli.py ADDED
@@ -0,0 +1,36 @@
1
+ """Installed EasyMoDD command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from importlib.metadata import version, PackageNotFoundError
7
+
8
+ from .config import load_config
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(prog="easymodd", description="Configurable training tools")
13
+ parser.add_argument("command", choices=["validate-config", "inspect-config", "version"], help="Operation to perform")
14
+ parser.add_argument("--config", default=None, help="Consumer-project TOML config path")
15
+ args = parser.parse_args()
16
+ if args.command == "version":
17
+ try:
18
+ print(version("easymodd"))
19
+ except PackageNotFoundError:
20
+ print("0.1.0 (source checkout)")
21
+ return
22
+ if not args.config:
23
+ parser.error(f"--config is required for {args.command}; EasyMoDD does not ship a project config")
24
+ if args.command == "validate-config":
25
+ config = load_config(args.config)
26
+ print(f"Valid EasyMoDD config: {config.source}")
27
+ else:
28
+ config = load_config(args.config)
29
+ for section, values in config.values.items():
30
+ print(f"[{section}]")
31
+ for key, value in values.items():
32
+ print(f"{key} = {value!r}")
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
easymodd/config.py ADDED
@@ -0,0 +1,56 @@
1
+ """Configuration loading and validation for EasyMoDD projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ import tomllib
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class EasyMoDDConfig:
13
+ """Validated TOML configuration with paths resolved from its location."""
14
+
15
+ values: dict[str, Any]
16
+ source: Path
17
+
18
+ def get(self, section: str, key: str, default: Any = None) -> Any:
19
+ return self.values.get(section, {}).get(key, default)
20
+
21
+ def path(self, section: str, key: str, default: str | None = None) -> Path | None:
22
+ value = self.get(section, key, default)
23
+ if value is None:
24
+ return None
25
+ path = Path(str(value))
26
+ return path if path.is_absolute() else (self.source.parent / path).resolve()
27
+
28
+
29
+ def load_config(path: str | Path = "config.txt") -> EasyMoDDConfig:
30
+ """Load TOML from a `.txt` config file and report actionable errors."""
31
+ source = Path(path).resolve()
32
+ try:
33
+ raw = tomllib.loads(source.read_text(encoding="utf-8"))
34
+ except FileNotFoundError as error:
35
+ raise FileNotFoundError(
36
+ f"EasyMoDD config was not found: {source}. Create config.txt first."
37
+ ) from error
38
+ except tomllib.TOMLDecodeError as error:
39
+ raise ValueError(f"Invalid TOML in {source}: {error}") from error
40
+
41
+ if not isinstance(raw, dict):
42
+ raise ValueError("EasyMoDD config must contain TOML sections")
43
+ allowed_sections = {
44
+ "project", "paths", "data", "model", "training", "validation",
45
+ "checkpoints", "device", "logging",
46
+ }
47
+ unknown = sorted(set(raw) - allowed_sections)
48
+ if unknown:
49
+ raise ValueError(f"Unknown config section(s): {', '.join(unknown)}")
50
+ checkpoints = raw.get("checkpoints", {})
51
+ if not isinstance(checkpoints, dict):
52
+ raise ValueError("[checkpoints] must be a TOML table")
53
+ retention = checkpoints.get("keep_recent", 2)
54
+ if not isinstance(retention, int) or retention < 1:
55
+ raise ValueError("[checkpoints] keep_recent must be an integer >= 1")
56
+ return EasyMoDDConfig(values=raw, source=source)
easymodd/data.py ADDED
@@ -0,0 +1,127 @@
1
+ """Small dataset primitives shared by EasyMoDD trainers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import numpy as np
7
+
8
+
9
+ class Dataset:
10
+ """Minimal indexable dataset contract."""
11
+
12
+ def __len__(self):
13
+ raise NotImplementedError
14
+
15
+ def __getitem__(self, index):
16
+ raise NotImplementedError
17
+
18
+
19
+ class BatchLoader:
20
+ """Deterministic mini-batch iterator for any indexable Dataset."""
21
+
22
+ def __init__(self, dataset: Dataset, batch_size: int = 32, shuffle: bool = True,
23
+ drop_last: bool = False, seed: int = 42):
24
+ if batch_size < 1:
25
+ raise ValueError("batch_size must be at least 1")
26
+ self.dataset = dataset
27
+ self.batch_size = batch_size
28
+ self.shuffle = shuffle
29
+ self.drop_last = drop_last
30
+ self.seed = seed
31
+
32
+ def __iter__(self):
33
+ rng = np.random.default_rng(self.seed)
34
+ indices = np.arange(len(self.dataset))
35
+ if self.shuffle:
36
+ rng.shuffle(indices)
37
+ for start in range(0, len(indices), self.batch_size):
38
+ batch_indices = indices[start:start + self.batch_size]
39
+ if self.drop_last and len(batch_indices) < self.batch_size:
40
+ continue
41
+ yield [self.dataset[int(index)] for index in batch_indices]
42
+
43
+ def __len__(self):
44
+ size = len(self.dataset) // self.batch_size
45
+ return size if self.drop_last else (size + bool(len(self.dataset) % self.batch_size))
46
+
47
+
48
+ def split_dataset(dataset: Dataset, validation_fraction: float = 0.1, seed: int = 42):
49
+ """Return disjoint train and validation Subset objects."""
50
+ if not 0 < validation_fraction < 1:
51
+ raise ValueError("validation_fraction must be between 0 and 1")
52
+ indices = np.arange(len(dataset))
53
+ np.random.default_rng(seed).shuffle(indices)
54
+ cutoff = max(1, int(len(indices) * (1 - validation_fraction)))
55
+ return Subset(dataset, indices[:cutoff]), Subset(dataset, indices[cutoff:])
56
+
57
+
58
+ class Subset(Dataset):
59
+ def __init__(self, dataset: Dataset, indices):
60
+ if len(indices) == 0:
61
+ raise ValueError("subset cannot be empty")
62
+ self.dataset = dataset
63
+ self.indices = np.asarray(indices)
64
+
65
+ def __len__(self):
66
+ return len(self.indices)
67
+
68
+ def __getitem__(self, index):
69
+ return self.dataset[int(self.indices[index])]
70
+
71
+
72
+ class ArrayDataset(Dataset):
73
+ """Dataset for aligned NumPy/CuPy-compatible input and target arrays."""
74
+
75
+ def __init__(self, inputs, targets, transform=None, target_transform=None):
76
+ if len(inputs) != len(targets):
77
+ raise ValueError("inputs and targets must contain the same number of rows")
78
+ if len(inputs) == 0:
79
+ raise ValueError("dataset cannot be empty")
80
+ self.inputs = inputs
81
+ self.targets = targets
82
+ self.transform = transform
83
+ self.target_transform = target_transform
84
+
85
+ def __len__(self):
86
+ return len(self.inputs)
87
+
88
+ def __getitem__(self, index):
89
+ inputs, targets = self.inputs[index], self.targets[index]
90
+ return (self.transform(inputs) if self.transform else inputs,
91
+ self.target_transform(targets) if self.target_transform else targets)
92
+
93
+
94
+ def collate_batch(items):
95
+ if not items:
96
+ raise ValueError("Cannot collate an empty batch")
97
+ inputs, targets = zip(*items)
98
+ return np.stack(inputs), np.stack(targets)
99
+
100
+
101
+ class TextDataset(Dataset):
102
+ """Packed next-token windows from a UTF-8 text file."""
103
+
104
+ def __init__(self, source: str | Path, tokenizer, sequence_length: int):
105
+ if sequence_length < 2:
106
+ raise ValueError("sequence_length must be at least 2")
107
+ path = Path(source)
108
+ if not path.is_file():
109
+ raise FileNotFoundError(f"Text dataset was not found: {path}")
110
+ tokens = tokenizer.encode(path.read_text(encoding="utf-8"))
111
+ self.windows = []
112
+ for start in range(0, len(tokens) - sequence_length, sequence_length):
113
+ window = tokens[start : start + sequence_length + 1]
114
+ if len(window) == sequence_length + 1:
115
+ self.windows.append(np.asarray(window, dtype=np.int32))
116
+ if len(self.windows) < 2:
117
+ raise ValueError(
118
+ f"Text dataset {path} is too small for sequence_length={sequence_length}; "
119
+ "provide more text or reduce the sequence length."
120
+ )
121
+
122
+ def __len__(self):
123
+ return len(self.windows)
124
+
125
+ def __getitem__(self, index):
126
+ window = self.windows[index]
127
+ return window[:-1], window[1:]
@@ -0,0 +1,37 @@
1
+ """Lightweight local experiment tracking with no external service required."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+
10
+ class Experiment:
11
+ """Record configuration and metrics locally, optionally integrating later."""
12
+
13
+ def __init__(self, name: str, directory: str | Path = "runs", config: dict | None = None):
14
+ if not name.strip():
15
+ raise ValueError("Experiment name cannot be empty")
16
+ self.name = name
17
+ self.directory = Path(directory) / name
18
+ self.directory.mkdir(parents=True, exist_ok=True)
19
+ self.config = config or {}
20
+ self._write_json("config.json", self.config)
21
+
22
+ def log(self, metrics: dict[str, float], step: int | None = None) -> None:
23
+ event = {"time": datetime.now(timezone.utc).isoformat(), "step": step, **metrics}
24
+ with (self.directory / "metrics.jsonl").open("a", encoding="utf-8") as handle:
25
+ handle.write(json.dumps(event) + "\n")
26
+
27
+ def artifact(self, name: str, content: str | bytes) -> Path:
28
+ path = self.directory / name
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ if isinstance(content, bytes):
31
+ path.write_bytes(content)
32
+ else:
33
+ path.write_text(content, encoding="utf-8")
34
+ return path
35
+
36
+ def _write_json(self, name: str, value) -> None:
37
+ (self.directory / name).write_text(json.dumps(value, indent=2), encoding="utf-8")
easymodd/losses.py ADDED
@@ -0,0 +1,57 @@
1
+ """Loss functions with explicit gradient helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ class MSELoss:
9
+ """Mean squared error for regression and educational custom loops."""
10
+
11
+ def __call__(self, predictions, targets) -> float:
12
+ return float(np.mean((predictions - targets) ** 2))
13
+
14
+ def gradient(self, predictions, targets):
15
+ return 2.0 * (predictions - targets) / predictions.size
16
+
17
+
18
+ class CrossEntropyLoss:
19
+ """Stable softmax cross-entropy for class-index targets."""
20
+
21
+ def __call__(self, logits, targets) -> float:
22
+ shifted = logits - np.max(logits, axis=-1, keepdims=True)
23
+ logsumexp = np.log(np.sum(np.exp(shifted), axis=-1, keepdims=True))
24
+ rows = np.arange(len(targets))
25
+ return float(np.mean(-shifted[rows, targets] + logsumexp[:, 0]))
26
+
27
+ def gradient(self, logits, targets):
28
+ shifted = logits - np.max(logits, axis=-1, keepdims=True)
29
+ probabilities = np.exp(shifted)
30
+ probabilities /= np.sum(probabilities, axis=-1, keepdims=True)
31
+ probabilities[np.arange(len(targets)), targets] -= 1
32
+ return probabilities / len(targets)
33
+
34
+
35
+ class L1Loss:
36
+ def __call__(self, predictions, targets) -> float:
37
+ return float(np.mean(np.abs(predictions - targets)))
38
+
39
+ def gradient(self, predictions, targets):
40
+ return np.sign(predictions - targets) / predictions.size
41
+
42
+
43
+ class HuberLoss:
44
+ def __init__(self, delta: float = 1.0):
45
+ if delta <= 0:
46
+ raise ValueError("delta must be greater than zero")
47
+ self.delta = delta
48
+
49
+ def __call__(self, predictions, targets) -> float:
50
+ error = np.abs(predictions - targets)
51
+ quadratic = np.minimum(error, self.delta)
52
+ linear = error - quadratic
53
+ return float(np.mean(0.5 * quadratic ** 2 + self.delta * linear))
54
+
55
+ def gradient(self, predictions, targets):
56
+ error = predictions - targets
57
+ return np.where(np.abs(error) <= self.delta, error, self.delta * np.sign(error)) / predictions.size
easymodd/metrics.py ADDED
@@ -0,0 +1,18 @@
1
+ """Training metrics that work with ordinary array-like predictions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ def accuracy(predictions, targets) -> float:
9
+ predicted = np.argmax(predictions, axis=-1) if getattr(predictions, "ndim", 0) > 1 else np.rint(predictions)
10
+ return float(np.mean(predicted == targets))
11
+
12
+
13
+ def mean_absolute_error(predictions, targets) -> float:
14
+ return float(np.mean(np.abs(predictions - targets)))
15
+
16
+
17
+ def perplexity(cross_entropy: float) -> float:
18
+ return float(np.exp(np.clip(cross_entropy, -50, 50)))
@@ -0,0 +1,7 @@
1
+ """Built-in model families."""
2
+
3
+ from .mlp import MLP, build_mlp
4
+ from .text_transformer import TextTransformerConfig
5
+ from .vision_transformer import VisionTransformerConfig
6
+
7
+ __all__ = ["MLP", "TextTransformerConfig", "VisionTransformerConfig", "build_mlp"]
easymodd/models/mlp.py ADDED
@@ -0,0 +1,24 @@
1
+ """General-purpose multilayer perceptron."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..nn import Linear, ReLU, Sequential
6
+
7
+
8
+ class MLP(Sequential):
9
+ """Composable feed-forward model for regression and classification."""
10
+
11
+ def __init__(self, input_size: int, hidden_sizes: list[int], output_size: int,
12
+ device: str = "cpu"):
13
+ modules = []
14
+ current = input_size
15
+ for hidden_size in hidden_sizes:
16
+ modules.extend([Linear(current, hidden_size, device=device), ReLU()])
17
+ current = hidden_size
18
+ modules.append(Linear(current, output_size, device=device))
19
+ super().__init__(*modules)
20
+
21
+
22
+ def build_mlp(input_size: int, hidden_sizes: list[int], output_size: int,
23
+ device: str = "cpu") -> MLP:
24
+ return MLP(input_size, hidden_sizes, output_size, device=device)
@@ -0,0 +1,29 @@
1
+ """Configuration contract for decoder-only text Transformers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class TextTransformerConfig:
10
+ """Validated architecture settings for a causal text model."""
11
+
12
+ vocab_size: int
13
+ embed_dim: int = 512
14
+ heads: int = 8
15
+ layers: int = 4
16
+ ff_dim: int = 2048
17
+ sequence_length: int = 512
18
+
19
+ def __post_init__(self) -> None:
20
+ if min(self.vocab_size, self.embed_dim, self.heads, self.layers, self.ff_dim) < 1:
21
+ raise ValueError("Text Transformer dimensions must be positive")
22
+ if self.embed_dim % self.heads:
23
+ raise ValueError("embed_dim must be divisible by heads")
24
+ if self.sequence_length < 2:
25
+ raise ValueError("sequence_length must be at least 2")
26
+
27
+ @property
28
+ def head_dim(self) -> int:
29
+ return self.embed_dim // self.heads
@@ -0,0 +1,30 @@
1
+ """Configuration contract for Vision Transformer models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class VisionTransformerConfig:
10
+ image_size: int = 224
11
+ patch_size: int = 16
12
+ channels: int = 3
13
+ classes: int = 10
14
+ embed_dim: int = 384
15
+ heads: int = 6
16
+ layers: int = 6
17
+ ff_dim: int = 1536
18
+
19
+ def __post_init__(self) -> None:
20
+ if self.image_size % self.patch_size:
21
+ raise ValueError("image_size must be divisible by patch_size")
22
+ if min(self.patch_size, self.channels, self.classes, self.embed_dim, self.heads, self.layers, self.ff_dim) < 1:
23
+ raise ValueError("Vision Transformer dimensions must be positive")
24
+ if self.embed_dim % self.heads:
25
+ raise ValueError("embed_dim must be divisible by heads")
26
+
27
+ @property
28
+ def patch_count(self) -> int:
29
+ patches_per_side = self.image_size // self.patch_size
30
+ return patches_per_side * patches_per_side