tensorless 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.
- tensorless/__init__.py +40 -0
- tensorless/_version.py +6 -0
- tensorless/api.py +230 -0
- tensorless/auto/__init__.py +4 -0
- tensorless/auto/config.py +113 -0
- tensorless/auto/detector.py +95 -0
- tensorless/checkpoint/__init__.py +3 -0
- tensorless/checkpoint/manager.py +66 -0
- tensorless/cli/__init__.py +3 -0
- tensorless/cli/main.py +121 -0
- tensorless/config.py +121 -0
- tensorless/data/__init__.py +11 -0
- tensorless/data/fingerprint.py +71 -0
- tensorless/data/inspector.py +161 -0
- tensorless/data/loader.py +255 -0
- tensorless/data/tabular.py +179 -0
- tensorless/devices/__init__.py +3 -0
- tensorless/devices/device.py +107 -0
- tensorless/errors.py +36 -0
- tensorless/models/__init__.py +5 -0
- tensorless/models/mlp.py +64 -0
- tensorless/models/registry.py +53 -0
- tensorless/models/transformer.py +175 -0
- tensorless/runtime.py +158 -0
- tensorless/serialization/__init__.py +3 -0
- tensorless/serialization/tl_format.py +89 -0
- tensorless/tokenization/__init__.py +3 -0
- tensorless/tokenization/char_tokenizer.py +84 -0
- tensorless/training/__init__.py +4 -0
- tensorless/training/data_prep.py +215 -0
- tensorless/training/early_stopping.py +31 -0
- tensorless/training/trainer.py +234 -0
- tensorless-0.1.0.dist-info/METADATA +111 -0
- tensorless-0.1.0.dist-info/RECORD +38 -0
- tensorless-0.1.0.dist-info/WHEEL +5 -0
- tensorless-0.1.0.dist-info/entry_points.txt +2 -0
- tensorless-0.1.0.dist-info/licenses/LICENSE +21 -0
- tensorless-0.1.0.dist-info/top_level.txt +1 -0
tensorless/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tensorless
|
|
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/tensorless/tensorless for full documentation.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .api import train, 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
|
+
"run",
|
|
30
|
+
"load",
|
|
31
|
+
"inspect",
|
|
32
|
+
"TrainConfig",
|
|
33
|
+
"TensorlessError",
|
|
34
|
+
"DataError",
|
|
35
|
+
"ConfigError",
|
|
36
|
+
"ModelError",
|
|
37
|
+
"CheckpointError",
|
|
38
|
+
"SerializationError",
|
|
39
|
+
"__version__",
|
|
40
|
+
]
|
tensorless/_version.py
ADDED
tensorless/api.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Public Tensorless 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 os
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from .config import TrainConfig
|
|
19
|
+
from .errors import ConfigError, DataError, ModelError
|
|
20
|
+
from .data.loader import load_dataset
|
|
21
|
+
from .data.fingerprint import fingerprint_path
|
|
22
|
+
from .data.inspector import inspect_path, InspectionReport
|
|
23
|
+
from .auto.config import resolve_config
|
|
24
|
+
from .checkpoint.manager import CheckpointManager
|
|
25
|
+
from .training.trainer import run_training
|
|
26
|
+
from .serialization.tl_format import save_tl, load_tl
|
|
27
|
+
from .runtime import LoadedModel, load_model
|
|
28
|
+
from ._version import __version__ as _tl_version, TL_FORMAT_VERSION as _tl_format_version
|
|
29
|
+
|
|
30
|
+
_KNOWN_FIELDS = {f.name for f in dataclasses.fields(TrainConfig)}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _build_train_config(**kwargs: Any) -> TrainConfig:
|
|
34
|
+
unknown = set(kwargs) - _KNOWN_FIELDS
|
|
35
|
+
if unknown:
|
|
36
|
+
raise ConfigError(
|
|
37
|
+
f"Unknown train() argument(s): {sorted(unknown)}. "
|
|
38
|
+
f"Valid arguments: {sorted(_KNOWN_FIELDS)}"
|
|
39
|
+
)
|
|
40
|
+
return TrainConfig(**kwargs)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def inspect(path: str) -> InspectionReport:
|
|
44
|
+
"""Inspect a dataset: detected task, size, problems, recommendations.
|
|
45
|
+
|
|
46
|
+
Does not train anything. Prints a human-readable report and also
|
|
47
|
+
returns a structured `InspectionReport` object.
|
|
48
|
+
"""
|
|
49
|
+
report = inspect_path(path)
|
|
50
|
+
print(report)
|
|
51
|
+
return report
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def train(path: str, **kwargs: Any) -> LoadedModel:
|
|
55
|
+
"""Train a model on the dataset at `path`, fully automatically by
|
|
56
|
+
default. Any field of `TrainConfig` can be overridden via keyword
|
|
57
|
+
argument, e.g. `tl.train("./data", d_model=512, layers=6)`.
|
|
58
|
+
|
|
59
|
+
Implements the "Smart Auto Check":
|
|
60
|
+
- if an up-to-date trained model already exists for this exact
|
|
61
|
+
dataset -> return it without retraining
|
|
62
|
+
- if training was interrupted on this exact dataset -> resume
|
|
63
|
+
- if the dataset changed -> retrain (or raise, if
|
|
64
|
+
`ask_on_data_change=True`), unless `force=True` is passed
|
|
65
|
+
"""
|
|
66
|
+
user_cfg = _build_train_config(**kwargs)
|
|
67
|
+
out = user_cfg.out or "model.tl"
|
|
68
|
+
checkpoint_dir = user_cfg.checkpoint_dir or (out + ".ckpt")
|
|
69
|
+
checkpoint_mgr = CheckpointManager(checkpoint_dir)
|
|
70
|
+
|
|
71
|
+
if not os.path.exists(path):
|
|
72
|
+
raise DataError(f"Path '{path}' does not exist.")
|
|
73
|
+
|
|
74
|
+
fingerprint = fingerprint_path(path)
|
|
75
|
+
|
|
76
|
+
resume_state = None
|
|
77
|
+
|
|
78
|
+
if not user_cfg.force:
|
|
79
|
+
# 1. Is there already a complete, up-to-date .tl file?
|
|
80
|
+
try:
|
|
81
|
+
existing = load_tl(out)
|
|
82
|
+
except Exception:
|
|
83
|
+
existing = None
|
|
84
|
+
if existing is not None:
|
|
85
|
+
if existing.get("dataset_fingerprint") == fingerprint and existing.get("training_complete"):
|
|
86
|
+
if user_cfg.verbose:
|
|
87
|
+
print(
|
|
88
|
+
f"[tensorless] '{out}' already exists and matches this "
|
|
89
|
+
f"dataset (fingerprint {fingerprint[:12]}...) -- using "
|
|
90
|
+
f"the existing model. Pass force=True to retrain."
|
|
91
|
+
)
|
|
92
|
+
return LoadedModel(existing)
|
|
93
|
+
|
|
94
|
+
# 2. Is there an interrupted / matching checkpoint to resume from?
|
|
95
|
+
if checkpoint_mgr.exists():
|
|
96
|
+
ckpt = checkpoint_mgr.load()
|
|
97
|
+
if ckpt.get("dataset_fingerprint") == fingerprint:
|
|
98
|
+
if ckpt.get("training_complete"):
|
|
99
|
+
# Training finished but the final .tl wasn't written
|
|
100
|
+
# (e.g. process died right after the last checkpoint).
|
|
101
|
+
# No need to retrain -- just package the .tl file.
|
|
102
|
+
return _finalize_from_checkpoint(ckpt, out, user_cfg.verbose)
|
|
103
|
+
resume_state = ckpt
|
|
104
|
+
if user_cfg.verbose:
|
|
105
|
+
print(
|
|
106
|
+
f"[tensorless] found an interrupted checkpoint for this "
|
|
107
|
+
f"exact dataset -- resuming training."
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
if user_cfg.ask_on_data_change:
|
|
111
|
+
raise ConfigError(
|
|
112
|
+
"The dataset has changed since the existing checkpoint "
|
|
113
|
+
"was created. Pass force=True to retrain from scratch, "
|
|
114
|
+
"or ask_on_data_change=False to retrain automatically."
|
|
115
|
+
)
|
|
116
|
+
if user_cfg.verbose:
|
|
117
|
+
print(
|
|
118
|
+
"[tensorless] dataset has changed since the last "
|
|
119
|
+
"checkpoint -- retraining from scratch."
|
|
120
|
+
)
|
|
121
|
+
checkpoint_mgr.clear()
|
|
122
|
+
else:
|
|
123
|
+
checkpoint_mgr.clear()
|
|
124
|
+
|
|
125
|
+
ds = load_dataset(path)
|
|
126
|
+
resolved = resolve_config(ds, user_cfg)
|
|
127
|
+
cfg = resolved.to_dict()
|
|
128
|
+
|
|
129
|
+
if resume_state is not None:
|
|
130
|
+
# Resumed runs must keep the exact architecture/config used
|
|
131
|
+
# originally, regardless of any new overrides, so the checkpoint
|
|
132
|
+
# can actually be loaded.
|
|
133
|
+
cfg = resume_state["config"]
|
|
134
|
+
|
|
135
|
+
result = run_training(
|
|
136
|
+
ds=ds,
|
|
137
|
+
cfg=cfg,
|
|
138
|
+
checkpoint_mgr=checkpoint_mgr,
|
|
139
|
+
dataset_fingerprint=fingerprint,
|
|
140
|
+
resume_state=resume_state,
|
|
141
|
+
log_fn=print if cfg.get("verbose", True) else (lambda *a, **k: None),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
payload = {
|
|
145
|
+
"tl_format_version": _tl_format_version,
|
|
146
|
+
"tensorless_version": _tl_version,
|
|
147
|
+
"task": cfg["task"],
|
|
148
|
+
"model_type": cfg["model_type"],
|
|
149
|
+
"config": cfg,
|
|
150
|
+
"meta": result["meta"],
|
|
151
|
+
"model_state_dict": result["model_state_dict"],
|
|
152
|
+
"tokenizer_state": result["tokenizer"].state_dict() if result["tokenizer"] else None,
|
|
153
|
+
"preprocessor_state": result["preprocessor"].state_dict() if result["preprocessor"] else None,
|
|
154
|
+
"dataset_fingerprint": fingerprint,
|
|
155
|
+
"training_complete": True,
|
|
156
|
+
"metrics": result["metrics"],
|
|
157
|
+
}
|
|
158
|
+
save_tl(out, payload)
|
|
159
|
+
if cfg.get("verbose", True):
|
|
160
|
+
print(f"[tensorless] saved trained model to '{out}'")
|
|
161
|
+
|
|
162
|
+
return LoadedModel(payload)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _finalize_from_checkpoint(ckpt: dict, out: str, verbose: bool) -> LoadedModel:
|
|
166
|
+
payload = {
|
|
167
|
+
"tl_format_version": _tl_format_version,
|
|
168
|
+
"tensorless_version": _tl_version,
|
|
169
|
+
"task": ckpt["config"]["task"],
|
|
170
|
+
"model_type": ckpt["config"]["model_type"],
|
|
171
|
+
"config": ckpt["config"],
|
|
172
|
+
"meta": ckpt["meta"],
|
|
173
|
+
"model_state_dict": ckpt["model_state_dict"],
|
|
174
|
+
"tokenizer_state": ckpt.get("tokenizer_state"),
|
|
175
|
+
"preprocessor_state": ckpt.get("preprocessor_state"),
|
|
176
|
+
"dataset_fingerprint": ckpt["dataset_fingerprint"],
|
|
177
|
+
"training_complete": True,
|
|
178
|
+
"metrics": {},
|
|
179
|
+
}
|
|
180
|
+
save_tl(out, payload)
|
|
181
|
+
if verbose:
|
|
182
|
+
print(f"[tensorless] finalized already-complete checkpoint into '{out}'")
|
|
183
|
+
return LoadedModel(payload)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def load(path: str, device: Optional[str] = None) -> LoadedModel:
|
|
187
|
+
"""Load a trained `.tl` model for inference."""
|
|
188
|
+
return load_model(path, device=device)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run(path: str, prompt: Optional[str] = None) -> Any:
|
|
192
|
+
"""Run a trained `.tl` model.
|
|
193
|
+
|
|
194
|
+
For text-generation models with no prompt given, starts an
|
|
195
|
+
interactive terminal chat. Otherwise runs one generation/prediction
|
|
196
|
+
and returns/prints the result.
|
|
197
|
+
"""
|
|
198
|
+
model = load_model(path)
|
|
199
|
+
if model.task == "text-generation":
|
|
200
|
+
if prompt is None:
|
|
201
|
+
model.chat()
|
|
202
|
+
return None
|
|
203
|
+
result = model.generate(prompt)
|
|
204
|
+
print(result)
|
|
205
|
+
return result
|
|
206
|
+
elif model.task == "text-classification":
|
|
207
|
+
if prompt is None:
|
|
208
|
+
print(
|
|
209
|
+
f"Loaded a '{model.task}' model. Use tl.load('{path}').predict(text) "
|
|
210
|
+
f"to classify text, or pass prompt=... / --prompt on the CLI."
|
|
211
|
+
)
|
|
212
|
+
return None
|
|
213
|
+
result = model.predict(prompt)
|
|
214
|
+
print(result)
|
|
215
|
+
return result
|
|
216
|
+
else:
|
|
217
|
+
# tabular classification/regression: needs a structured record, not
|
|
218
|
+
# free text, so the CLI --prompt shortcut doesn't apply here.
|
|
219
|
+
if prompt is not None:
|
|
220
|
+
raise ModelError(
|
|
221
|
+
f"'{path}' is a tabular '{model.task}' model, which expects a "
|
|
222
|
+
f"structured record (e.g. {{'age': 30, 'income': 90000}}), not "
|
|
223
|
+
f"free text. Use tl.load('{path}').predict({{...}}) from Python "
|
|
224
|
+
f"instead of --prompt on the CLI."
|
|
225
|
+
)
|
|
226
|
+
print(
|
|
227
|
+
f"Loaded a tabular '{model.task}' model. Use "
|
|
228
|
+
f"tl.load('{path}').predict({{...}}) to run predictions."
|
|
229
|
+
)
|
|
230
|
+
return None
|
|
@@ -0,0 +1,113 @@
|
|
|
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) -> int:
|
|
47
|
+
if n_examples < 200:
|
|
48
|
+
return 8
|
|
49
|
+
elif n_examples < 2000:
|
|
50
|
+
return 16
|
|
51
|
+
elif n_examples < 20000:
|
|
52
|
+
return 32
|
|
53
|
+
else:
|
|
54
|
+
return 64
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _auto_epochs(n_examples: int) -> int:
|
|
58
|
+
if n_examples < 200:
|
|
59
|
+
return 40
|
|
60
|
+
elif n_examples < 2000:
|
|
61
|
+
return 20
|
|
62
|
+
elif n_examples < 20000:
|
|
63
|
+
return 10
|
|
64
|
+
else:
|
|
65
|
+
return 5
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_config(ds: Dataset, user: TrainConfig) -> ResolvedConfig:
|
|
69
|
+
n = len(ds)
|
|
70
|
+
task = user.task or detect_task(ds)
|
|
71
|
+
model_type = user.model_type or (
|
|
72
|
+
"transformer" if task in ("text-generation", "text-classification") else "mlp"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
d_model, layers, heads, ff_mult = _auto_model_size(n, ds.kind)
|
|
76
|
+
|
|
77
|
+
device, precision = auto_select_device(user.device, user.precision)
|
|
78
|
+
|
|
79
|
+
out = user.out or "model.tl"
|
|
80
|
+
checkpoint_dir = user.checkpoint_dir or (out + ".ckpt")
|
|
81
|
+
|
|
82
|
+
resolved = ResolvedConfig(
|
|
83
|
+
out=out,
|
|
84
|
+
force=bool(user.force),
|
|
85
|
+
resume=user.resume,
|
|
86
|
+
ask_on_data_change=bool(user.ask_on_data_change),
|
|
87
|
+
task=task,
|
|
88
|
+
model_type=model_type,
|
|
89
|
+
d_model=user.d_model or d_model,
|
|
90
|
+
layers=user.layers or layers,
|
|
91
|
+
heads=user.heads or heads,
|
|
92
|
+
ff_mult=user.ff_mult or ff_mult,
|
|
93
|
+
dropout=user.dropout if user.dropout is not None else 0.1,
|
|
94
|
+
max_seq_len=user.max_seq_len or (256 if ds.kind in ("text", "text_labeled") else 1),
|
|
95
|
+
optimizer=user.optimizer or "adamw",
|
|
96
|
+
learning_rate=user.learning_rate or (3e-4 if model_type == "transformer" else 1e-3),
|
|
97
|
+
weight_decay=user.weight_decay if user.weight_decay is not None else 0.01,
|
|
98
|
+
batch_size=user.batch_size or _auto_batch_size(n),
|
|
99
|
+
epochs=user.epochs or _auto_epochs(n),
|
|
100
|
+
max_steps=user.max_steps,
|
|
101
|
+
grad_clip=user.grad_clip if user.grad_clip is not None else 1.0,
|
|
102
|
+
warmup_steps=user.warmup_steps if user.warmup_steps is not None else min(100, max(1, n // 10)),
|
|
103
|
+
val_split=user.val_split if user.val_split is not None else (0.1 if n >= 50 else 0.0),
|
|
104
|
+
patience=user.patience or 5,
|
|
105
|
+
min_delta=user.min_delta if user.min_delta is not None else 1e-4,
|
|
106
|
+
device=device,
|
|
107
|
+
precision=precision,
|
|
108
|
+
checkpoint_every=user.checkpoint_every or 50,
|
|
109
|
+
checkpoint_dir=checkpoint_dir,
|
|
110
|
+
seed=user.seed,
|
|
111
|
+
verbose=user.verbose,
|
|
112
|
+
)
|
|
113
|
+
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 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,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)
|
tensorless/cli/main.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
tensorless train ./data
|
|
4
|
+
tensorless run model.tl
|
|
5
|
+
tensorless inspect ./data
|
|
6
|
+
tensorless info model.tl
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from .. import api
|
|
16
|
+
from ..errors import TensorlessError
|
|
17
|
+
from ..serialization.tl_format import load_tl
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _add_train_parser(subparsers) -> None:
|
|
21
|
+
p = subparsers.add_parser("train", help="Train a model on a dataset (fully automatic by default)")
|
|
22
|
+
p.add_argument("path", help="Path to a dataset file or directory")
|
|
23
|
+
p.add_argument("--out", default=None, help="Output .tl file path (default: model.tl)")
|
|
24
|
+
p.add_argument("--force", action="store_true", help="Force retraining even if a matching model exists")
|
|
25
|
+
p.add_argument("--d-model", type=int, default=None, dest="d_model")
|
|
26
|
+
p.add_argument("--layers", type=int, default=None)
|
|
27
|
+
p.add_argument("--heads", type=int, default=None)
|
|
28
|
+
p.add_argument("--batch-size", type=int, default=None, dest="batch_size")
|
|
29
|
+
p.add_argument("--epochs", type=int, default=None)
|
|
30
|
+
p.add_argument("--learning-rate", type=float, default=None, dest="learning_rate")
|
|
31
|
+
p.add_argument("--device", default=None, choices=["cpu", "cuda", "tpu", "mps"])
|
|
32
|
+
p.add_argument("--quiet", action="store_true", help="Suppress training logs")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _add_run_parser(subparsers) -> None:
|
|
36
|
+
p = subparsers.add_parser("run", help="Run a trained .tl model")
|
|
37
|
+
p.add_argument("path", help="Path to a .tl model file")
|
|
38
|
+
p.add_argument("--prompt", default=None, help="Input text/prompt (skips interactive chat)")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _add_inspect_parser(subparsers) -> None:
|
|
42
|
+
p = subparsers.add_parser("inspect", help="Inspect a dataset without training")
|
|
43
|
+
p.add_argument("path", help="Path to a dataset file or directory")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _add_info_parser(subparsers) -> None:
|
|
47
|
+
p = subparsers.add_parser("info", help="Show information about a trained .tl model")
|
|
48
|
+
p.add_argument("path", help="Path to a .tl model file")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
52
|
+
parser = argparse.ArgumentParser(prog="tensorless", description="Tensorless: ML with maximum automation.")
|
|
53
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
54
|
+
_add_train_parser(subparsers)
|
|
55
|
+
_add_run_parser(subparsers)
|
|
56
|
+
_add_inspect_parser(subparsers)
|
|
57
|
+
_add_info_parser(subparsers)
|
|
58
|
+
return parser
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv=None) -> int:
|
|
62
|
+
parser = build_parser()
|
|
63
|
+
args = parser.parse_args(argv)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
if args.command == "train":
|
|
67
|
+
overrides = {}
|
|
68
|
+
if args.out:
|
|
69
|
+
overrides["out"] = args.out
|
|
70
|
+
if args.force:
|
|
71
|
+
overrides["force"] = True
|
|
72
|
+
if args.d_model:
|
|
73
|
+
overrides["d_model"] = args.d_model
|
|
74
|
+
if args.layers:
|
|
75
|
+
overrides["layers"] = args.layers
|
|
76
|
+
if args.heads:
|
|
77
|
+
overrides["heads"] = args.heads
|
|
78
|
+
if args.batch_size:
|
|
79
|
+
overrides["batch_size"] = args.batch_size
|
|
80
|
+
if args.epochs:
|
|
81
|
+
overrides["epochs"] = args.epochs
|
|
82
|
+
if args.learning_rate:
|
|
83
|
+
overrides["learning_rate"] = args.learning_rate
|
|
84
|
+
if args.device:
|
|
85
|
+
overrides["device"] = args.device
|
|
86
|
+
if args.quiet:
|
|
87
|
+
overrides["verbose"] = False
|
|
88
|
+
api.train(args.path, **overrides)
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
elif args.command == "run":
|
|
92
|
+
api.run(args.path, prompt=args.prompt)
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
elif args.command == "inspect":
|
|
96
|
+
api.inspect(args.path)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
elif args.command == "info":
|
|
100
|
+
payload = load_tl(args.path)
|
|
101
|
+
info = {
|
|
102
|
+
"task": payload["task"],
|
|
103
|
+
"model_type": payload["model_type"],
|
|
104
|
+
"tensorless_version": payload.get("tensorless_version"),
|
|
105
|
+
"tl_format_version": payload.get("tl_format_version"),
|
|
106
|
+
"training_complete": payload.get("training_complete"),
|
|
107
|
+
"metrics": payload.get("metrics"),
|
|
108
|
+
"dataset_fingerprint": (payload.get("dataset_fingerprint") or "")[:16],
|
|
109
|
+
}
|
|
110
|
+
print(json.dumps(info, indent=2, default=str))
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
except TensorlessError as e:
|
|
114
|
+
print(f"tensorless: error: {e}", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
sys.exit(main())
|