@tuned-tensor/local 0.1.0
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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +161 -0
- package/README.md +242 -0
- package/dist/artifacts.d.ts +35 -0
- package/dist/artifacts.js +56 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/contracts.d.ts +471 -0
- package/dist/contracts.js +253 -0
- package/dist/contracts.js.map +1 -0
- package/dist/dataset.d.ts +12 -0
- package/dist/dataset.js +60 -0
- package/dist/dataset.js.map +1 -0
- package/dist/docker-training.d.ts +8 -0
- package/dist/docker-training.js +123 -0
- package/dist/docker-training.js.map +1 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +74 -0
- package/dist/doctor.js.map +1 -0
- package/dist/evaluation.d.ts +23 -0
- package/dist/evaluation.js +318 -0
- package/dist/evaluation.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +316 -0
- package/dist/index.js.map +1 -0
- package/dist/local-project.d.ts +22 -0
- package/dist/local-project.js +74 -0
- package/dist/local-project.js.map +1 -0
- package/dist/model-registry.d.ts +18 -0
- package/dist/model-registry.js +151 -0
- package/dist/model-registry.js.map +1 -0
- package/dist/openrouter.d.ts +16 -0
- package/dist/openrouter.js +39 -0
- package/dist/openrouter.js.map +1 -0
- package/dist/orchestrator.d.ts +14 -0
- package/dist/orchestrator.js +151 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/process-training.d.ts +8 -0
- package/dist/process-training.js +135 -0
- package/dist/process-training.js.map +1 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +211 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +98 -0
- package/dist/store.js +327 -0
- package/dist/store.js.map +1 -0
- package/docs/architecture.md +109 -0
- package/docs/spark.md +86 -0
- package/examples/local-runner.json +14 -0
- package/examples/smoke-run-request.json +34 -0
- package/package.json +40 -0
- package/training/sft-local/pyproject.toml +24 -0
- package/training/sft-local/src/evaluate.py +177 -0
- package/training/sft-local/src/train.py +233 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tarfile
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tempfile import TemporaryDirectory
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
from peft import PeftModel
|
|
14
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_json(path: Path) -> dict[str, Any]:
|
|
18
|
+
return json.loads(path.read_text())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def strip_file_uri(value: str | None) -> str | None:
|
|
22
|
+
if not value:
|
|
23
|
+
return value
|
|
24
|
+
if value.startswith("file://"):
|
|
25
|
+
return value[7:]
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_adapter_path(value: str | None, tmp: Path) -> str | None:
|
|
30
|
+
path_value = strip_file_uri(value)
|
|
31
|
+
if not path_value:
|
|
32
|
+
return None
|
|
33
|
+
path = Path(path_value)
|
|
34
|
+
if path.is_file() and path.name.endswith(".tar.gz"):
|
|
35
|
+
extracted = tmp / "adapter"
|
|
36
|
+
extracted.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
with tarfile.open(path, "r:gz") as tar:
|
|
38
|
+
for member in tar.getmembers():
|
|
39
|
+
destination = (extracted / member.name).resolve()
|
|
40
|
+
if not str(destination).startswith(str(extracted.resolve())):
|
|
41
|
+
raise ValueError(f"Unsafe archive member: {member.name}")
|
|
42
|
+
tar.extractall(extracted)
|
|
43
|
+
candidates = [candidate for candidate in extracted.rglob("adapter_config.json")]
|
|
44
|
+
if candidates:
|
|
45
|
+
return str(candidates[0].parent)
|
|
46
|
+
directories = [candidate for candidate in extracted.iterdir() if candidate.is_dir()]
|
|
47
|
+
return str(directories[0] if len(directories) == 1 else extracted)
|
|
48
|
+
return str(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fallback_prompt(system: str, prompt: str) -> str:
|
|
52
|
+
return f"system: {system}\nuser: {prompt}\nassistant:"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_prompt(tokenizer: Any, system: str, prompt: str) -> str:
|
|
56
|
+
messages = [
|
|
57
|
+
{"role": "system", "content": system},
|
|
58
|
+
{"role": "user", "content": prompt},
|
|
59
|
+
]
|
|
60
|
+
try:
|
|
61
|
+
return tokenizer.apply_chat_template(
|
|
62
|
+
messages,
|
|
63
|
+
tokenize=False,
|
|
64
|
+
add_generation_prompt=True,
|
|
65
|
+
)
|
|
66
|
+
except Exception:
|
|
67
|
+
return fallback_prompt(system, prompt)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_device(device: str) -> str:
|
|
71
|
+
if device == "auto":
|
|
72
|
+
if torch.cuda.is_available():
|
|
73
|
+
return "cuda"
|
|
74
|
+
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
|
75
|
+
return "mps"
|
|
76
|
+
return "cpu"
|
|
77
|
+
return device
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_model(payload: dict[str, Any], adapter_path: str | None):
|
|
81
|
+
base_model = payload["base_model"]
|
|
82
|
+
trust_remote_code = bool(payload.get("trust_remote_code", True))
|
|
83
|
+
device = resolve_device(str(payload.get("device", "auto")))
|
|
84
|
+
token = os.getenv("HF_TOKEN")
|
|
85
|
+
|
|
86
|
+
tokenizer = AutoTokenizer.from_pretrained(
|
|
87
|
+
base_model,
|
|
88
|
+
trust_remote_code=trust_remote_code,
|
|
89
|
+
token=token,
|
|
90
|
+
)
|
|
91
|
+
if tokenizer.pad_token is None:
|
|
92
|
+
tokenizer.pad_token = tokenizer.eos_token
|
|
93
|
+
|
|
94
|
+
dtype = None
|
|
95
|
+
if device == "cuda":
|
|
96
|
+
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
|
97
|
+
|
|
98
|
+
kwargs: dict[str, Any] = {
|
|
99
|
+
"trust_remote_code": trust_remote_code,
|
|
100
|
+
"token": token,
|
|
101
|
+
}
|
|
102
|
+
if dtype is not None:
|
|
103
|
+
kwargs["torch_dtype"] = dtype
|
|
104
|
+
if device == "cuda":
|
|
105
|
+
kwargs["device_map"] = "auto"
|
|
106
|
+
|
|
107
|
+
model = AutoModelForCausalLM.from_pretrained(base_model, **kwargs)
|
|
108
|
+
if adapter_path:
|
|
109
|
+
model = PeftModel.from_pretrained(model, adapter_path)
|
|
110
|
+
if device != "cuda":
|
|
111
|
+
model = model.to(device)
|
|
112
|
+
model.eval()
|
|
113
|
+
return model, tokenizer, device
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def generate_one(model: Any, tokenizer: Any, device: str, system: str, example: dict[str, Any], generation: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
prompt = str(example["input"])
|
|
118
|
+
expected = str(example["output"])
|
|
119
|
+
formatted = format_prompt(tokenizer, system, prompt)
|
|
120
|
+
inputs = tokenizer(formatted, return_tensors="pt")
|
|
121
|
+
target_device = next(model.parameters()).device
|
|
122
|
+
inputs = {key: value.to(target_device) for key, value in inputs.items()}
|
|
123
|
+
started = time.perf_counter()
|
|
124
|
+
with torch.no_grad():
|
|
125
|
+
generated = model.generate(
|
|
126
|
+
**inputs,
|
|
127
|
+
max_new_tokens=int(generation.get("max_new_tokens", 256)),
|
|
128
|
+
do_sample=float(generation.get("temperature", 0)) > 0,
|
|
129
|
+
temperature=max(float(generation.get("temperature", 0)), 1e-5),
|
|
130
|
+
top_p=float(generation.get("top_p", 1)),
|
|
131
|
+
pad_token_id=tokenizer.eos_token_id,
|
|
132
|
+
)
|
|
133
|
+
latency_ms = max(0, round((time.perf_counter() - started) * 1000))
|
|
134
|
+
input_length = int(inputs["input_ids"].shape[-1])
|
|
135
|
+
output_tokens = generated[0][input_length:]
|
|
136
|
+
actual = tokenizer.decode(output_tokens, skip_special_tokens=True).strip()
|
|
137
|
+
return {
|
|
138
|
+
"prompt": prompt,
|
|
139
|
+
"expected": expected,
|
|
140
|
+
"actual": actual,
|
|
141
|
+
"latency_ms": latency_ms,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main() -> None:
|
|
146
|
+
parser = argparse.ArgumentParser()
|
|
147
|
+
parser.add_argument("--input", required=True)
|
|
148
|
+
parser.add_argument("--output", required=True)
|
|
149
|
+
args = parser.parse_args()
|
|
150
|
+
|
|
151
|
+
payload = load_json(Path(args.input))
|
|
152
|
+
if payload.get("model_cache"):
|
|
153
|
+
os.environ["HF_HOME"] = str(payload["model_cache"])
|
|
154
|
+
|
|
155
|
+
with TemporaryDirectory() as tmp_dir:
|
|
156
|
+
adapter_path = resolve_adapter_path(payload.get("adapter_path"), Path(tmp_dir))
|
|
157
|
+
model, tokenizer, device = load_model(payload, adapter_path)
|
|
158
|
+
generation = payload.get("generation", {})
|
|
159
|
+
results = [
|
|
160
|
+
generate_one(model, tokenizer, device, str(payload.get("system", "")), example, generation)
|
|
161
|
+
for example in payload.get("examples", [])
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
output = {
|
|
165
|
+
"provider": "transformers",
|
|
166
|
+
"model_id": payload.get("model_id"),
|
|
167
|
+
"base_model": payload.get("base_model"),
|
|
168
|
+
"adapter_path": payload.get("adapter_path"),
|
|
169
|
+
"generation_config": generation,
|
|
170
|
+
"results": results,
|
|
171
|
+
}
|
|
172
|
+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
Path(args.output).write_text(json.dumps(output, indent=2))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
main()
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tarfile
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from tempfile import TemporaryDirectory
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import torch
|
|
13
|
+
from peft import LoraConfig, get_peft_model
|
|
14
|
+
from torch.utils.data import Dataset
|
|
15
|
+
from transformers import (
|
|
16
|
+
AutoModelForCausalLM,
|
|
17
|
+
AutoTokenizer,
|
|
18
|
+
DataCollatorForLanguageModeling,
|
|
19
|
+
Trainer,
|
|
20
|
+
TrainingArguments,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
TRAINING_DIR = Path(os.environ.get("SM_CHANNEL_TRAINING", "/opt/ml/input/data/training"))
|
|
25
|
+
BASE_MODEL_DIR = Path(os.environ.get("SM_CHANNEL_BASE_MODEL", "/opt/ml/input/data/base_model"))
|
|
26
|
+
HYPERPARAMETERS_PATH = Path(
|
|
27
|
+
os.environ.get("TT_HYPERPARAMETERS_PATH", "/opt/ml/input/config/hyperparameters.json")
|
|
28
|
+
)
|
|
29
|
+
MODEL_DIR = Path(os.environ.get("SM_MODEL_DIR", "/opt/ml/model"))
|
|
30
|
+
OUTPUT_DIR = Path(os.environ.get("SM_OUTPUT_DIR", "/opt/ml/output"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_hyperparameters() -> dict[str, str]:
|
|
34
|
+
if not HYPERPARAMETERS_PATH.is_file():
|
|
35
|
+
return {}
|
|
36
|
+
raw = json.loads(HYPERPARAMETERS_PATH.read_text())
|
|
37
|
+
return {str(key): str(value) for key, value in raw.items()}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
HP = load_hyperparameters()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def hp(name: str, default: str | None = None) -> str | None:
|
|
44
|
+
value = os.getenv(f"SM_HP_{name.upper()}", HP.get(name, default))
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
value = str(value).strip()
|
|
48
|
+
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
49
|
+
return value[1:-1]
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def hp_int(name: str, default: int) -> int:
|
|
54
|
+
return int(hp(name, str(default)) or default)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def hp_float(name: str, default: float) -> float:
|
|
58
|
+
return float(hp(name, str(default)) or default)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def hp_bool(name: str, default: bool) -> bool:
|
|
62
|
+
return (hp(name, str(default)) or "").lower() in {"1", "true", "yes", "y"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_rows() -> list[dict[str, Any]]:
|
|
66
|
+
rows: list[dict[str, Any]] = []
|
|
67
|
+
for path in sorted(TRAINING_DIR.rglob("*.jsonl")):
|
|
68
|
+
for line_number, line in enumerate(path.read_text().splitlines(), start=1):
|
|
69
|
+
if not line.strip():
|
|
70
|
+
continue
|
|
71
|
+
row = json.loads(line)
|
|
72
|
+
if "messages" not in row:
|
|
73
|
+
raise ValueError(f"{path}:{line_number} missing messages")
|
|
74
|
+
rows.append(row)
|
|
75
|
+
if not rows:
|
|
76
|
+
raise ValueError(f"No chat JSONL rows found under {TRAINING_DIR}")
|
|
77
|
+
return rows
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_model_source() -> str:
|
|
81
|
+
archive = BASE_MODEL_DIR / "model.tar.gz"
|
|
82
|
+
if archive.is_file():
|
|
83
|
+
extracted = Path("/tmp/base-model")
|
|
84
|
+
extracted.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
with tarfile.open(archive, "r:gz") as tar:
|
|
86
|
+
tar.extractall(extracted)
|
|
87
|
+
candidates = [path for path in extracted.iterdir() if path.is_dir()]
|
|
88
|
+
return str(candidates[0] if len(candidates) == 1 else extracted)
|
|
89
|
+
return hp("base_model") or "Qwen/Qwen3.5-2B"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def row_to_text(tokenizer: Any, row: dict[str, Any]) -> str:
|
|
93
|
+
messages = row["messages"]
|
|
94
|
+
try:
|
|
95
|
+
return tokenizer.apply_chat_template(
|
|
96
|
+
messages,
|
|
97
|
+
tokenize=False,
|
|
98
|
+
add_generation_prompt=False,
|
|
99
|
+
)
|
|
100
|
+
except Exception:
|
|
101
|
+
parts: list[str] = []
|
|
102
|
+
for message in messages:
|
|
103
|
+
role = message.get("role", "user")
|
|
104
|
+
content = message.get("content", "")
|
|
105
|
+
if not isinstance(content, str):
|
|
106
|
+
content = json.dumps(content, ensure_ascii=False)
|
|
107
|
+
parts.append(f"{role}: {content}")
|
|
108
|
+
return "\n".join(parts)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TextDataset(Dataset[dict[str, torch.Tensor]]):
|
|
112
|
+
def __init__(self, texts: list[str], tokenizer: Any, max_length: int):
|
|
113
|
+
self.examples = [
|
|
114
|
+
tokenizer(
|
|
115
|
+
text,
|
|
116
|
+
truncation=True,
|
|
117
|
+
max_length=max_length,
|
|
118
|
+
padding=False,
|
|
119
|
+
return_tensors=None,
|
|
120
|
+
)
|
|
121
|
+
for text in texts
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
def __len__(self) -> int:
|
|
125
|
+
return len(self.examples)
|
|
126
|
+
|
|
127
|
+
def __getitem__(self, index: int) -> dict[str, torch.Tensor]:
|
|
128
|
+
item = self.examples[index]
|
|
129
|
+
return {
|
|
130
|
+
"input_ids": torch.tensor(item["input_ids"], dtype=torch.long),
|
|
131
|
+
"attention_mask": torch.tensor(item["attention_mask"], dtype=torch.long),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def create_model_and_tokenizer(model_source: str):
|
|
136
|
+
trust_remote_code = hp_bool("trust_remote_code", True)
|
|
137
|
+
token = os.getenv("HF_TOKEN")
|
|
138
|
+
tokenizer = AutoTokenizer.from_pretrained(
|
|
139
|
+
model_source,
|
|
140
|
+
trust_remote_code=trust_remote_code,
|
|
141
|
+
token=token,
|
|
142
|
+
)
|
|
143
|
+
if tokenizer.pad_token is None:
|
|
144
|
+
tokenizer.pad_token = tokenizer.eos_token
|
|
145
|
+
|
|
146
|
+
dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
|
|
147
|
+
model = AutoModelForCausalLM.from_pretrained(
|
|
148
|
+
model_source,
|
|
149
|
+
trust_remote_code=trust_remote_code,
|
|
150
|
+
token=token,
|
|
151
|
+
torch_dtype=dtype if torch.cuda.is_available() else None,
|
|
152
|
+
device_map="auto" if torch.cuda.is_available() else None,
|
|
153
|
+
)
|
|
154
|
+
model.config.use_cache = False
|
|
155
|
+
return model, tokenizer
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def maybe_apply_lora(model: Any):
|
|
159
|
+
rank = hp_int("lora_rank", 16)
|
|
160
|
+
alpha = hp_int("lora_alpha", 32)
|
|
161
|
+
dropout = hp_float("lora_dropout", 0.05)
|
|
162
|
+
config = LoraConfig(
|
|
163
|
+
r=rank,
|
|
164
|
+
lora_alpha=alpha,
|
|
165
|
+
lora_dropout=dropout,
|
|
166
|
+
bias="none",
|
|
167
|
+
task_type="CAUSAL_LM",
|
|
168
|
+
target_modules="all-linear",
|
|
169
|
+
)
|
|
170
|
+
return get_peft_model(model, config)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def create_model_archive() -> Path:
|
|
174
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
archive_path = OUTPUT_DIR / "model.tar.gz"
|
|
176
|
+
with tarfile.open(archive_path, "w:gz") as tar:
|
|
177
|
+
tar.add(MODEL_DIR, arcname="model")
|
|
178
|
+
return archive_path
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main() -> None:
|
|
182
|
+
started = time.time()
|
|
183
|
+
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
|
|
186
|
+
rows = load_rows()
|
|
187
|
+
model_source = resolve_model_source()
|
|
188
|
+
model, tokenizer = create_model_and_tokenizer(model_source)
|
|
189
|
+
model = maybe_apply_lora(model)
|
|
190
|
+
|
|
191
|
+
texts = [row_to_text(tokenizer, row) for row in rows]
|
|
192
|
+
dataset = TextDataset(texts, tokenizer, max_length=hp_int("max_seq_length", 2048))
|
|
193
|
+
collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)
|
|
194
|
+
|
|
195
|
+
with TemporaryDirectory() as tmp:
|
|
196
|
+
args = TrainingArguments(
|
|
197
|
+
output_dir=tmp,
|
|
198
|
+
num_train_epochs=hp_int("n_epochs", 3),
|
|
199
|
+
learning_rate=hp_float("learning_rate", 0.00001),
|
|
200
|
+
per_device_train_batch_size=hp_int("per_device_train_batch_size", 1),
|
|
201
|
+
gradient_accumulation_steps=hp_int("gradient_accumulation_steps", 8),
|
|
202
|
+
logging_steps=1,
|
|
203
|
+
save_strategy="no",
|
|
204
|
+
report_to=[],
|
|
205
|
+
remove_unused_columns=False,
|
|
206
|
+
bf16=torch.cuda.is_available() and torch.cuda.is_bf16_supported(),
|
|
207
|
+
fp16=torch.cuda.is_available() and not torch.cuda.is_bf16_supported(),
|
|
208
|
+
)
|
|
209
|
+
trainer = Trainer(
|
|
210
|
+
model=model,
|
|
211
|
+
args=args,
|
|
212
|
+
train_dataset=dataset,
|
|
213
|
+
data_collator=collator,
|
|
214
|
+
)
|
|
215
|
+
result = trainer.train()
|
|
216
|
+
|
|
217
|
+
model.save_pretrained(MODEL_DIR)
|
|
218
|
+
tokenizer.save_pretrained(MODEL_DIR)
|
|
219
|
+
archive_path = create_model_archive()
|
|
220
|
+
metrics = {
|
|
221
|
+
"training_rows": len(rows),
|
|
222
|
+
"model_source": model_source,
|
|
223
|
+
"train_runtime": round(time.time() - started, 3),
|
|
224
|
+
**{key: float(value) for key, value in result.metrics.items() if isinstance(value, (int, float))},
|
|
225
|
+
"model_archive": str(archive_path),
|
|
226
|
+
}
|
|
227
|
+
(MODEL_DIR / "training-metrics.json").write_text(json.dumps(metrics, indent=2))
|
|
228
|
+
(OUTPUT_DIR / "training-metrics.json").write_text(json.dumps(metrics, indent=2))
|
|
229
|
+
print(json.dumps(metrics, indent=2))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
main()
|