tensorless-pytorch 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.
Files changed (40) hide show
  1. tensorless/__init__.py +41 -0
  2. tensorless/_version.py +6 -0
  3. tensorless/api.py +246 -0
  4. tensorless/auto/__init__.py +4 -0
  5. tensorless/auto/config.py +135 -0
  6. tensorless/auto/detector.py +95 -0
  7. tensorless/checkpoint/__init__.py +3 -0
  8. tensorless/checkpoint/manager.py +66 -0
  9. tensorless/cli/__init__.py +3 -0
  10. tensorless/cli/main.py +121 -0
  11. tensorless/config.py +125 -0
  12. tensorless/data/__init__.py +11 -0
  13. tensorless/data/english_grammar.txt +262 -0
  14. tensorless/data/fingerprint.py +71 -0
  15. tensorless/data/inspector.py +161 -0
  16. tensorless/data/loader.py +255 -0
  17. tensorless/data/tabular.py +213 -0
  18. tensorless/devices/__init__.py +3 -0
  19. tensorless/devices/device.py +107 -0
  20. tensorless/errors.py +36 -0
  21. tensorless/models/__init__.py +5 -0
  22. tensorless/models/mlp.py +64 -0
  23. tensorless/models/registry.py +53 -0
  24. tensorless/models/transformer.py +175 -0
  25. tensorless/runtime.py +158 -0
  26. tensorless/serialization/__init__.py +3 -0
  27. tensorless/serialization/tl_format.py +119 -0
  28. tensorless/tokenization/__init__.py +11 -0
  29. tensorless/tokenization/bpe_tokenizer.py +128 -0
  30. tensorless/tokenization/char_tokenizer.py +84 -0
  31. tensorless/training/__init__.py +4 -0
  32. tensorless/training/data_prep.py +238 -0
  33. tensorless/training/early_stopping.py +31 -0
  34. tensorless/training/trainer.py +260 -0
  35. tensorless_pytorch-0.1.0.dist-info/METADATA +82 -0
  36. tensorless_pytorch-0.1.0.dist-info/RECORD +40 -0
  37. tensorless_pytorch-0.1.0.dist-info/WHEEL +5 -0
  38. tensorless_pytorch-0.1.0.dist-info/entry_points.txt +2 -0
  39. tensorless_pytorch-0.1.0.dist-info/licenses/LICENSE +21 -0
  40. tensorless_pytorch-0.1.0.dist-info/top_level.txt +1 -0
tensorless/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """
2
+ Tensorless PyTorch
3
+ ==========
4
+
5
+ ML with maximum automation and minimum setup.
6
+
7
+ import tensorless as tl
8
+
9
+ tl.train("./data")
10
+ tl.run("model.tl")
11
+
12
+ See https://github.com/DeveloperPuneet/Tensorless-Pytorch for full documentation.
13
+ """
14
+
15
+ from .api import train, pretrain, run, load, inspect
16
+ from .config import TrainConfig
17
+ from .errors import (
18
+ TensorlessError,
19
+ DataError,
20
+ ConfigError,
21
+ ModelError,
22
+ CheckpointError,
23
+ SerializationError,
24
+ )
25
+ from ._version import __version__
26
+
27
+ __all__ = [
28
+ "train",
29
+ "pretrain",
30
+ "run",
31
+ "load",
32
+ "inspect",
33
+ "TrainConfig",
34
+ "TensorlessError",
35
+ "DataError",
36
+ "ConfigError",
37
+ "ModelError",
38
+ "CheckpointError",
39
+ "SerializationError",
40
+ "__version__",
41
+ ]
tensorless/_version.py ADDED
@@ -0,0 +1,6 @@
1
+ __version__ = "0.1.0"
2
+
3
+ # Bump this whenever the .tl serialization layout changes in a
4
+ # backward-incompatible way. Stored inside every .tl file so old
5
+ # files can be detected and (eventually) migrated.
6
+ TL_FORMAT_VERSION = 1
tensorless/api.py ADDED
@@ -0,0 +1,246 @@
1
+ """Public Tensorless PyTorch API.
2
+
3
+ import tensorless as tl
4
+
5
+ tl.train("./data") # full auto
6
+ tl.train("./data", d_model=512, layers=6, batch_size=32) # override anything
7
+ tl.inspect("./data")
8
+ model = tl.load("model.tl")
9
+ tl.run("model.tl")
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import importlib.resources
16
+ import os
17
+ from typing import Any, Optional
18
+
19
+ from .config import TrainConfig
20
+ from .errors import ConfigError, DataError, ModelError
21
+ from .data.loader import load_dataset
22
+ from .data.fingerprint import fingerprint_path
23
+ from .data.inspector import inspect_path, InspectionReport
24
+ from .auto.config import resolve_config
25
+ from .checkpoint.manager import CheckpointManager
26
+ from .training.trainer import run_training
27
+ from .serialization.tl_format import save_tl, load_tl
28
+ from .runtime import LoadedModel, load_model
29
+ from ._version import __version__ as _tl_version, TL_FORMAT_VERSION as _tl_format_version
30
+
31
+ _KNOWN_FIELDS = {f.name for f in dataclasses.fields(TrainConfig)}
32
+
33
+
34
+ def _build_train_config(**kwargs: Any) -> TrainConfig:
35
+ unknown = set(kwargs) - _KNOWN_FIELDS
36
+ if unknown:
37
+ raise ConfigError(
38
+ f"Unknown train() argument(s): {sorted(unknown)}. "
39
+ f"Valid arguments: {sorted(_KNOWN_FIELDS)}"
40
+ )
41
+ return TrainConfig(**kwargs)
42
+
43
+
44
+ def inspect(path: str) -> InspectionReport:
45
+ """Inspect a dataset: detected task, size, problems, recommendations.
46
+
47
+ Does not train anything. Prints a human-readable report and also
48
+ returns a structured `InspectionReport` object.
49
+ """
50
+ report = inspect_path(path)
51
+ print(report)
52
+ return report
53
+
54
+
55
+ def train(path: str, **kwargs: Any) -> LoadedModel:
56
+ """Train a model on the dataset at `path`, fully automatically by
57
+ default. Any field of `TrainConfig` can be overridden via keyword
58
+ argument, e.g. `tl.train("./data", d_model=512, layers=6)`.
59
+
60
+ Implements the "Smart Auto Check":
61
+ - if an up-to-date trained model already exists for this exact
62
+ dataset -> return it without retraining
63
+ - if training was interrupted on this exact dataset -> resume
64
+ - if the dataset changed -> retrain (or raise, if
65
+ `ask_on_data_change=True`), unless `force=True` is passed
66
+ """
67
+ user_cfg = _build_train_config(**kwargs)
68
+ out = user_cfg.out or "model.tl"
69
+ checkpoint_dir = user_cfg.checkpoint_dir or (out + ".ckpt")
70
+ checkpoint_mgr = CheckpointManager(checkpoint_dir)
71
+
72
+ if not os.path.exists(path):
73
+ raise DataError(f"Path '{path}' does not exist.")
74
+
75
+ fingerprint = fingerprint_path(path)
76
+
77
+ resume_state = None
78
+
79
+ if not user_cfg.force:
80
+ # 1. Is there already a complete, up-to-date .tl file?
81
+ try:
82
+ existing = load_tl(out)
83
+ except Exception:
84
+ existing = None
85
+ if existing is not None:
86
+ if existing.get("dataset_fingerprint") == fingerprint and existing.get("training_complete"):
87
+ if user_cfg.verbose:
88
+ print(
89
+ f"[tensorless] '{out}' already exists and matches this "
90
+ f"dataset (fingerprint {fingerprint[:12]}...) -- using "
91
+ f"the existing model. Pass force=True to retrain."
92
+ )
93
+ return LoadedModel(existing)
94
+
95
+ # 2. Is there an interrupted / matching checkpoint to resume from?
96
+ if checkpoint_mgr.exists() and user_cfg.resume is not False:
97
+ ckpt = checkpoint_mgr.load()
98
+ if ckpt.get("dataset_fingerprint") == fingerprint:
99
+ if ckpt.get("training_complete"):
100
+ # Training finished but the final .tl wasn't written
101
+ # (e.g. process died right after the last checkpoint).
102
+ # No need to retrain -- just package the .tl file.
103
+ return _finalize_from_checkpoint(ckpt, out, user_cfg.verbose)
104
+ resume_state = ckpt
105
+ if user_cfg.verbose:
106
+ print(
107
+ f"[tensorless] found an interrupted checkpoint for this "
108
+ f"exact dataset -- resuming training."
109
+ )
110
+ else:
111
+ if user_cfg.ask_on_data_change:
112
+ raise ConfigError(
113
+ "The dataset has changed since the existing checkpoint "
114
+ "was created. Pass force=True to retrain from scratch, "
115
+ "or ask_on_data_change=False to retrain automatically."
116
+ )
117
+ if user_cfg.verbose:
118
+ print(
119
+ "[tensorless] dataset has changed since the last "
120
+ "checkpoint -- retraining from scratch."
121
+ )
122
+ checkpoint_mgr.clear()
123
+ elif checkpoint_mgr.exists():
124
+ if user_cfg.verbose:
125
+ print("[tensorless] resume=False -- ignoring the existing checkpoint.")
126
+ checkpoint_mgr.clear()
127
+ else:
128
+ checkpoint_mgr.clear()
129
+
130
+ ds = load_dataset(path)
131
+ resolved = resolve_config(ds, user_cfg)
132
+ cfg = resolved.to_dict()
133
+
134
+ if resume_state is not None:
135
+ # Resumed runs must keep the exact architecture/config used
136
+ # originally, regardless of any new overrides, so the checkpoint
137
+ # can actually be loaded.
138
+ cfg = resume_state["config"]
139
+
140
+ result = run_training(
141
+ ds=ds,
142
+ cfg=cfg,
143
+ checkpoint_mgr=checkpoint_mgr,
144
+ dataset_fingerprint=fingerprint,
145
+ resume_state=resume_state,
146
+ log_fn=print if cfg.get("verbose", True) else (lambda *a, **k: None),
147
+ )
148
+
149
+ payload = {
150
+ "tl_format_version": _tl_format_version,
151
+ "tensorless_version": _tl_version,
152
+ "task": cfg["task"],
153
+ "model_type": cfg["model_type"],
154
+ "config": cfg,
155
+ "meta": result["meta"],
156
+ "model_state_dict": result["model_state_dict"],
157
+ "tokenizer_state": result["tokenizer"].state_dict() if result["tokenizer"] else None,
158
+ "preprocessor_state": result["preprocessor"].state_dict() if result["preprocessor"] else None,
159
+ "dataset_fingerprint": fingerprint,
160
+ "training_complete": True,
161
+ "metrics": result["metrics"],
162
+ }
163
+ save_tl(out, payload)
164
+ if cfg.get("verbose", True):
165
+ print(f"[tensorless] saved trained model to '{out}'")
166
+
167
+ return LoadedModel(payload)
168
+
169
+
170
+ def pretrain(
171
+ out: str = "english_pretrained.tl", language: str = "english", **kwargs: Any
172
+ ) -> LoadedModel:
173
+ """Pretrain a small language model on the built-in starter corpus."""
174
+ if language.lower() != "english":
175
+ raise ValueError("The built-in pretraining corpus currently supports only 'english'.")
176
+ corpus = importlib.resources.files("tensorless.data").joinpath("english_grammar.txt")
177
+ options = {"task": "text-generation", "out": out, **kwargs}
178
+ return train(str(corpus), **options)
179
+
180
+
181
+ def _finalize_from_checkpoint(ckpt: dict, out: str, verbose: bool) -> LoadedModel:
182
+ payload = {
183
+ "tl_format_version": _tl_format_version,
184
+ "tensorless_version": _tl_version,
185
+ "task": ckpt["config"]["task"],
186
+ "model_type": ckpt["config"]["model_type"],
187
+ "config": ckpt["config"],
188
+ "meta": ckpt["meta"],
189
+ "model_state_dict": ckpt["model_state_dict"],
190
+ "tokenizer_state": ckpt.get("tokenizer_state"),
191
+ "preprocessor_state": ckpt.get("preprocessor_state"),
192
+ "dataset_fingerprint": ckpt["dataset_fingerprint"],
193
+ "training_complete": True,
194
+ "metrics": {},
195
+ }
196
+ save_tl(out, payload)
197
+ if verbose:
198
+ print(f"[tensorless] finalized already-complete checkpoint into '{out}'")
199
+ return LoadedModel(payload)
200
+
201
+
202
+ def load(path: str, device: Optional[str] = None) -> LoadedModel:
203
+ """Load a trained `.tl` model for inference."""
204
+ return load_model(path, device=device)
205
+
206
+
207
+ def run(path: str, prompt: Optional[str] = None) -> Any:
208
+ """Run a trained `.tl` model.
209
+
210
+ For text-generation models with no prompt given, starts an
211
+ interactive terminal chat. Otherwise runs one generation/prediction
212
+ and returns/prints the result.
213
+ """
214
+ model = load_model(path)
215
+ if model.task == "text-generation":
216
+ if prompt is None:
217
+ model.chat()
218
+ return None
219
+ result = model.generate(prompt)
220
+ print(result)
221
+ return result
222
+ elif model.task == "text-classification":
223
+ if prompt is None:
224
+ print(
225
+ f"Loaded a '{model.task}' model. Use tl.load('{path}').predict(text) "
226
+ f"to classify text, or pass prompt=... / --prompt on the CLI."
227
+ )
228
+ return None
229
+ result = model.predict(prompt)
230
+ print(result)
231
+ return result
232
+ else:
233
+ # tabular classification/regression: needs a structured record, not
234
+ # free text, so the CLI --prompt shortcut doesn't apply here.
235
+ if prompt is not None:
236
+ raise ModelError(
237
+ f"'{path}' is a tabular '{model.task}' model, which expects a "
238
+ f"structured record (e.g. {{'age': 30, 'income': 90000}}), not "
239
+ f"free text. Use tl.load('{path}').predict({{...}}) from Python "
240
+ f"instead of --prompt on the CLI."
241
+ )
242
+ print(
243
+ f"Loaded a tabular '{model.task}' model. Use "
244
+ f"tl.load('{path}').predict({{...}}) to run predictions."
245
+ )
246
+ return None
@@ -0,0 +1,4 @@
1
+ from .detector import detect_task
2
+ from .config import resolve_config
3
+
4
+ __all__ = ["detect_task", "resolve_config"]
@@ -0,0 +1,135 @@
1
+ """Automatic configuration.
2
+
3
+ Turns a `Dataset` + a user-supplied `TrainConfig` (with mostly-None fields)
4
+ into a fully-resolved `ResolvedConfig`. Every automatic choice is driven by
5
+ simple, explainable heuristics based on dataset size -- this is not meant
6
+ to be state-of-the-art NAS, it's meant to produce a *sane, working* default
7
+ so `tl.train("./data")` just works.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict
13
+
14
+ from ..config import TrainConfig, ResolvedConfig
15
+ from ..data.loader import Dataset
16
+ from ..devices.device import auto_select_device
17
+ from .detector import detect_task, target_column
18
+
19
+
20
+ def _auto_model_size(n_examples: int, kind: str):
21
+ """Return (d_model, layers, heads, ff_mult) scaled to dataset size.
22
+
23
+ Small datasets get small models (avoids absurd overfitting / long CPU
24
+ training times); larger datasets get bigger models. These are
25
+ deliberately modest sizes suitable for CPU-friendly experimentation --
26
+ users can always override with layers=, d_model=, etc.
27
+ """
28
+ if kind in ("text", "text_labeled"):
29
+ if n_examples < 200:
30
+ return 64, 2, 2, 2
31
+ elif n_examples < 5000:
32
+ return 128, 4, 4, 2
33
+ elif n_examples < 50000:
34
+ return 256, 6, 8, 4
35
+ else:
36
+ return 384, 8, 8, 4
37
+ else: # tabular
38
+ if n_examples < 500:
39
+ return 32, 2, 1, 2
40
+ elif n_examples < 20000:
41
+ return 64, 3, 1, 2
42
+ else:
43
+ return 128, 4, 1, 2
44
+
45
+
46
+ def _auto_batch_size(n_examples: int, max_seq_len: int) -> int:
47
+ token_budget = 8192
48
+ sequence_batch = max(1, token_budget // max_seq_len)
49
+ if n_examples < 200:
50
+ return min(8, sequence_batch)
51
+ elif n_examples < 2000:
52
+ return min(16, sequence_batch)
53
+ elif n_examples < 20000:
54
+ return min(32, sequence_batch)
55
+ else:
56
+ return min(64, sequence_batch)
57
+
58
+
59
+ def _auto_epochs(n_examples: int) -> int:
60
+ if n_examples < 200:
61
+ return 40
62
+ elif n_examples < 2000:
63
+ return 20
64
+ elif n_examples < 20000:
65
+ return 10
66
+ else:
67
+ return 5
68
+
69
+
70
+ def _effective_text_size(ds: Dataset) -> int:
71
+ """Estimate useful training examples for raw corpora."""
72
+ if ds.kind in ("text", "text_labeled"):
73
+ return max(len(ds), sum(len(text) for text in ds.texts) // 200)
74
+ return len(ds)
75
+
76
+
77
+ def _auto_vocab_size(ds: Dataset) -> int:
78
+ if ds.kind not in ("text", "text_labeled"):
79
+ return 1000
80
+ unique_chars = len(set("".join(ds.texts)))
81
+ return min(4096, max(64, unique_chars * 8))
82
+
83
+
84
+ def resolve_config(ds: Dataset, user: TrainConfig) -> ResolvedConfig:
85
+ n = _effective_text_size(ds)
86
+ task = user.task or detect_task(ds)
87
+ model_type = user.model_type or (
88
+ "transformer" if task in ("text-generation", "text-classification") else "mlp"
89
+ )
90
+ tokenizer = user.tokenizer or "bpe"
91
+ if tokenizer not in ("char", "bpe"):
92
+ raise ValueError("tokenizer must be 'char' or 'bpe'")
93
+
94
+ d_model, layers, heads, ff_mult = _auto_model_size(n, ds.kind)
95
+
96
+ device, precision = auto_select_device(user.device, user.precision)
97
+
98
+ out = user.out or "model.tl"
99
+ checkpoint_dir = user.checkpoint_dir or (out + ".ckpt")
100
+
101
+ max_seq_len = user.max_seq_len or (256 if ds.kind in ("text", "text_labeled") else 1)
102
+ resolved = ResolvedConfig(
103
+ out=out,
104
+ force=bool(user.force),
105
+ resume=user.resume,
106
+ ask_on_data_change=bool(user.ask_on_data_change),
107
+ task=task,
108
+ model_type=model_type,
109
+ d_model=user.d_model or d_model,
110
+ layers=user.layers or layers,
111
+ heads=user.heads or heads,
112
+ ff_mult=user.ff_mult or ff_mult,
113
+ dropout=user.dropout if user.dropout is not None else 0.1,
114
+ max_seq_len=max_seq_len,
115
+ tokenizer=tokenizer,
116
+ bpe_vocab_size=user.bpe_vocab_size if user.bpe_vocab_size is not None else _auto_vocab_size(ds),
117
+ optimizer=user.optimizer or "adamw",
118
+ learning_rate=user.learning_rate or (3e-4 if model_type == "transformer" else 1e-3),
119
+ weight_decay=user.weight_decay if user.weight_decay is not None else 0.01,
120
+ batch_size=user.batch_size if user.batch_size is not None else _auto_batch_size(n, max_seq_len),
121
+ epochs=user.epochs or _auto_epochs(n),
122
+ max_steps=user.max_steps,
123
+ grad_clip=user.grad_clip if user.grad_clip is not None else 1.0,
124
+ warmup_steps=user.warmup_steps if user.warmup_steps is not None else min(100, max(1, n // 10)),
125
+ val_split=user.val_split if user.val_split is not None else (0.1 if n >= 50 else 0.0),
126
+ patience=user.patience if user.patience is not None else 3,
127
+ min_delta=user.min_delta if user.min_delta is not None else 1e-4,
128
+ device=device,
129
+ precision=precision,
130
+ checkpoint_every=user.checkpoint_every or 50,
131
+ checkpoint_dir=checkpoint_dir,
132
+ seed=user.seed,
133
+ verbose=user.verbose,
134
+ )
135
+ return resolved
@@ -0,0 +1,95 @@
1
+ """Automatic task-type detection.
2
+
3
+ Given a loaded `Dataset`, decide what kind of ML task it represents:
4
+
5
+ - "text-generation" : raw text corpus -> train a language model
6
+ - "text-classification": (text, label) pairs -> classify text
7
+ - "classification" : tabular data with a categorical target
8
+ - "regression" : tabular data with a numeric target
9
+
10
+ The detector is intentionally simple and explainable: every decision can
11
+ be described in one sentence, which is important because `tl.inspect()`
12
+ reports *why* a task was chosen.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Optional
18
+
19
+ from ..data.loader import Dataset
20
+ from ..errors import DataError
21
+
22
+ _TARGET_CANDIDATES = ("label", "target", "class", "category", "y", "output")
23
+
24
+
25
+ def _looks_numeric(values) -> bool:
26
+ n_ok = 0
27
+ n_total = 0
28
+ for v in values:
29
+ if v is None or v == "":
30
+ continue
31
+ n_total += 1
32
+ try:
33
+ float(v)
34
+ n_ok += 1
35
+ except (TypeError, ValueError):
36
+ pass
37
+ if n_total == 0:
38
+ return False
39
+ return (n_ok / n_total) > 0.95
40
+
41
+
42
+ def _find_target_column(ds: Dataset) -> Optional[str]:
43
+ for cand in _TARGET_CANDIDATES:
44
+ for col in ds.columns:
45
+ if col.lower() == cand:
46
+ return col
47
+ # Fall back to the last column, a common convention in tabular datasets.
48
+ return ds.columns[-1] if ds.columns else None
49
+
50
+
51
+ def detect_task(ds: Dataset) -> str:
52
+ """Return one of "text-generation", "text-classification",
53
+ "classification", "regression".
54
+ """
55
+ if ds.kind == "text":
56
+ return "text-generation"
57
+
58
+ if ds.kind == "text_labeled":
59
+ return "text-classification"
60
+
61
+ if ds.kind == "tabular":
62
+ if not ds.records:
63
+ raise DataError("Tabular dataset has no rows.")
64
+ target = _find_target_column(ds)
65
+ if target is None:
66
+ raise DataError(
67
+ "Could not find a target/label column in the tabular data. "
68
+ "Tensorless PyTorch looks for a column named one of: "
69
+ f"{', '.join(_TARGET_CANDIDATES)} (or uses the last column)."
70
+ )
71
+ values = [r.get(target) for r in ds.records]
72
+ if _looks_numeric(values):
73
+ unique_vals = set(values)
74
+ # Small number of distinct numeric values that are all
75
+ # integer-like -> more likely classification (e.g. 0/1 labels)
76
+ # than regression.
77
+ if len(unique_vals) <= min(10, max(2, len(values) // 20)):
78
+ try:
79
+ all_int_like = all(float(v) == int(float(v)) for v in unique_vals if v not in (None, ""))
80
+ except (TypeError, ValueError):
81
+ all_int_like = False
82
+ if all_int_like:
83
+ return "classification"
84
+ return "regression"
85
+ return "classification"
86
+
87
+ raise DataError(f"Unknown dataset kind: {ds.kind}")
88
+
89
+
90
+ def target_column(ds: Dataset) -> Optional[str]:
91
+ """Public helper mirroring `_find_target_column`, used by the tabular
92
+ model pipeline so detection and training agree on which column is
93
+ the target.
94
+ """
95
+ return _find_target_column(ds)
@@ -0,0 +1,3 @@
1
+ from .manager import CheckpointManager
2
+
3
+ __all__ = ["CheckpointManager"]
@@ -0,0 +1,66 @@
1
+ """Checkpoint management.
2
+
3
+ Handles all the state needed to resume training safely and transparently:
4
+ model weights, optimizer state, scheduler state, epoch/step counters, the
5
+ resolved training config, tokenizer/preprocessor state, the dataset
6
+ fingerprint used for training, and the best-metric-so-far for early
7
+ stopping.
8
+
9
+ Users never touch this directly -- `tl.train()` decides automatically
10
+ whether to create, update, or resume from a checkpoint (see
11
+ `training/trainer.py` and the "Smart Auto Check" logic in `api.py`).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import tempfile
19
+ from typing import Any, Dict, Optional
20
+
21
+ import torch
22
+
23
+ from ..errors import CheckpointError
24
+
25
+ CHECKPOINT_FILENAME = "checkpoint.pt"
26
+
27
+
28
+ class CheckpointManager:
29
+ def __init__(self, checkpoint_dir: str):
30
+ self.checkpoint_dir = checkpoint_dir
31
+ self.path = os.path.join(checkpoint_dir, CHECKPOINT_FILENAME)
32
+
33
+ def exists(self) -> bool:
34
+ return os.path.isfile(self.path)
35
+
36
+ def save(self, state: Dict[str, Any]) -> None:
37
+ """Atomically write `state` to the checkpoint file.
38
+
39
+ Writes to a temp file first and renames it into place, so a crash
40
+ or interruption mid-write never leaves a corrupt checkpoint that
41
+ would block resumption.
42
+ """
43
+ os.makedirs(self.checkpoint_dir, exist_ok=True)
44
+ fd, tmp_path = tempfile.mkstemp(dir=self.checkpoint_dir, suffix=".tmp")
45
+ os.close(fd)
46
+ try:
47
+ torch.save(state, tmp_path)
48
+ shutil.move(tmp_path, self.path)
49
+ except Exception as e:
50
+ if os.path.exists(tmp_path):
51
+ os.remove(tmp_path)
52
+ raise CheckpointError(f"Failed to save checkpoint to '{self.path}': {e}") from e
53
+
54
+ def load(self, map_location: Optional[str] = None) -> Dict[str, Any]:
55
+ if not self.exists():
56
+ raise CheckpointError(f"No checkpoint found at '{self.path}'.")
57
+ try:
58
+ return torch.load(self.path, map_location=map_location, weights_only=False)
59
+ except Exception as e:
60
+ raise CheckpointError(
61
+ f"Checkpoint at '{self.path}' is corrupt or incompatible: {e}"
62
+ ) from e
63
+
64
+ def clear(self) -> None:
65
+ if os.path.isdir(self.checkpoint_dir):
66
+ shutil.rmtree(self.checkpoint_dir, ignore_errors=True)
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ __all__ = ["main"]