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 +85 -0
- easymodd/callbacks.py +40 -0
- easymodd/checkpoints.py +59 -0
- easymodd/cli.py +36 -0
- easymodd/config.py +56 -0
- easymodd/data.py +127 -0
- easymodd/experiments.py +37 -0
- easymodd/losses.py +57 -0
- easymodd/metrics.py +18 -0
- easymodd/models/__init__.py +7 -0
- easymodd/models/mlp.py +24 -0
- easymodd/models/text_transformer.py +29 -0
- easymodd/models/vision_transformer.py +30 -0
- easymodd/nn.py +165 -0
- easymodd/optim.py +76 -0
- easymodd/py.typed +0 -0
- easymodd/registry.py +38 -0
- easymodd/schedulers.py +37 -0
- easymodd/secret_store.py +30 -0
- easymodd/serialization.py +43 -0
- easymodd/tensor.py +69 -0
- easymodd/training.py +93 -0
- easymodd/transforms.py +47 -0
- easymodd/visualization.py +20 -0
- easymodd-0.1.0.dist-info/METADATA +231 -0
- easymodd-0.1.0.dist-info/RECORD +30 -0
- easymodd-0.1.0.dist-info/WHEEL +5 -0
- easymodd-0.1.0.dist-info/entry_points.txt +2 -0
- easymodd-0.1.0.dist-info/licenses/LICENSE +21 -0
- easymodd-0.1.0.dist-info/top_level.txt +1 -0
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
|
easymodd/checkpoints.py
ADDED
|
@@ -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:]
|
easymodd/experiments.py
ADDED
|
@@ -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)))
|
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
|