claude-turing 1.5.0 → 2.0.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/.claude-plugin/plugin.json +2 -2
- package/README.md +3 -2
- package/commands/export.md +48 -0
- package/commands/turing.md +2 -0
- package/package.json +1 -1
- package/src/install.js +1 -1
- package/src/verify.js +1 -0
- package/templates/scripts/__pycache__/equivalence_checker.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/export_card.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/export_formats.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/latency_benchmark.cpython-314.pyc +0 -0
- package/templates/scripts/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/templates/scripts/equivalence_checker.py +158 -0
- package/templates/scripts/export_card.py +183 -0
- package/templates/scripts/export_formats.py +385 -0
- package/templates/scripts/export_model.py +324 -0
- package/templates/scripts/latency_benchmark.py +167 -0
- package/templates/scripts/scaffold.py +6 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Format-specific model export handlers.
|
|
3
|
+
|
|
4
|
+
Each handler knows how to export a specific model type to a production
|
|
5
|
+
format. Returns the export path and metadata.
|
|
6
|
+
|
|
7
|
+
Supported formats:
|
|
8
|
+
- joblib: scikit-learn, XGBoost, LightGBM (default)
|
|
9
|
+
- xgboost_json: XGBoost native JSON format
|
|
10
|
+
- lightgbm_text: LightGBM native text format
|
|
11
|
+
- onnx: ONNX via framework-specific converters
|
|
12
|
+
- torchscript: PyTorch JIT trace
|
|
13
|
+
- tflite: TensorFlow Lite
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Registry of model types -> supported export formats
|
|
24
|
+
FORMAT_REGISTRY = {
|
|
25
|
+
"xgboost": ["joblib", "xgboost_json", "onnx"],
|
|
26
|
+
"lightgbm": ["joblib", "lightgbm_text", "onnx"],
|
|
27
|
+
"random_forest": ["joblib", "onnx"],
|
|
28
|
+
"gradient_boosting": ["joblib", "onnx"],
|
|
29
|
+
"logistic_regression": ["joblib", "onnx"],
|
|
30
|
+
"svm": ["joblib", "onnx"],
|
|
31
|
+
"mlp": ["joblib", "onnx"],
|
|
32
|
+
"pytorch": ["torchscript", "onnx"],
|
|
33
|
+
"tensorflow": ["tflite", "onnx"],
|
|
34
|
+
"keras": ["tflite", "onnx"],
|
|
35
|
+
"catboost": ["joblib", "onnx"],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Default format for each model type
|
|
39
|
+
DEFAULT_FORMAT = {
|
|
40
|
+
"xgboost": "joblib",
|
|
41
|
+
"lightgbm": "joblib",
|
|
42
|
+
"random_forest": "joblib",
|
|
43
|
+
"gradient_boosting": "joblib",
|
|
44
|
+
"logistic_regression": "joblib",
|
|
45
|
+
"svm": "joblib",
|
|
46
|
+
"mlp": "joblib",
|
|
47
|
+
"pytorch": "torchscript",
|
|
48
|
+
"tensorflow": "tflite",
|
|
49
|
+
"keras": "tflite",
|
|
50
|
+
"catboost": "joblib",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# File extensions for each format
|
|
54
|
+
FORMAT_EXTENSIONS = {
|
|
55
|
+
"joblib": ".joblib",
|
|
56
|
+
"xgboost_json": ".json",
|
|
57
|
+
"lightgbm_text": ".txt",
|
|
58
|
+
"onnx": ".onnx",
|
|
59
|
+
"torchscript": ".pt",
|
|
60
|
+
"tflite": ".tflite",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Dependencies required for each format
|
|
64
|
+
FORMAT_DEPENDENCIES = {
|
|
65
|
+
"joblib": ["joblib"],
|
|
66
|
+
"xgboost_json": ["xgboost>=1.7"],
|
|
67
|
+
"lightgbm_text": ["lightgbm>=3.0"],
|
|
68
|
+
"onnx": ["onnx", "onnxruntime"],
|
|
69
|
+
"torchscript": ["torch>=1.9"],
|
|
70
|
+
"tflite": ["tensorflow>=2.0"],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_supported_formats(model_type: str) -> list[str]:
|
|
75
|
+
"""Get supported export formats for a model type."""
|
|
76
|
+
return FORMAT_REGISTRY.get(model_type, ["joblib"])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_default_format(model_type: str) -> str:
|
|
80
|
+
"""Get the default export format for a model type."""
|
|
81
|
+
return DEFAULT_FORMAT.get(model_type, "joblib")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def detect_model_type(config: dict) -> str:
|
|
85
|
+
"""Detect model type from experiment config."""
|
|
86
|
+
model_type = config.get("model", {}).get("type", "")
|
|
87
|
+
if not model_type:
|
|
88
|
+
model_type = config.get("model_type", "unknown")
|
|
89
|
+
return model_type.lower().replace("-", "_").replace(" ", "_")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def export_joblib(
|
|
93
|
+
model_path: str,
|
|
94
|
+
output_dir: str,
|
|
95
|
+
model_name: str,
|
|
96
|
+
) -> dict:
|
|
97
|
+
"""Export model as joblib bundle (copy if already joblib, else convert).
|
|
98
|
+
|
|
99
|
+
Returns dict with path, size_bytes, format, and dependencies.
|
|
100
|
+
"""
|
|
101
|
+
src = Path(model_path)
|
|
102
|
+
if not src.exists():
|
|
103
|
+
return {"error": f"Model file not found: {model_path}"}
|
|
104
|
+
|
|
105
|
+
out_path = Path(output_dir)
|
|
106
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
dst = out_path / f"{model_name}.joblib"
|
|
109
|
+
shutil.copy2(str(src), str(dst))
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
"path": str(dst),
|
|
113
|
+
"format": "joblib",
|
|
114
|
+
"size_bytes": dst.stat().st_size,
|
|
115
|
+
"size_mb": round(dst.stat().st_size / 1024**2, 2),
|
|
116
|
+
"dependencies": FORMAT_DEPENDENCIES["joblib"],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def export_xgboost_json(
|
|
121
|
+
model_path: str,
|
|
122
|
+
output_dir: str,
|
|
123
|
+
model_name: str,
|
|
124
|
+
) -> dict:
|
|
125
|
+
"""Export XGBoost model to native JSON format."""
|
|
126
|
+
try:
|
|
127
|
+
import joblib
|
|
128
|
+
import xgboost as xgb
|
|
129
|
+
except ImportError as e:
|
|
130
|
+
return {"error": f"Missing dependency: {e}"}
|
|
131
|
+
|
|
132
|
+
src = Path(model_path)
|
|
133
|
+
if not src.exists():
|
|
134
|
+
return {"error": f"Model file not found: {model_path}"}
|
|
135
|
+
|
|
136
|
+
out_path = Path(output_dir)
|
|
137
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
model = joblib.load(str(src))
|
|
141
|
+
# Handle wrapped models (e.g., in a pipeline)
|
|
142
|
+
if hasattr(model, "get_booster"):
|
|
143
|
+
booster = model.get_booster()
|
|
144
|
+
elif isinstance(model, xgb.Booster):
|
|
145
|
+
booster = model
|
|
146
|
+
else:
|
|
147
|
+
return {"error": "Model is not an XGBoost model or doesn't have get_booster()"}
|
|
148
|
+
|
|
149
|
+
dst = out_path / f"{model_name}.json"
|
|
150
|
+
booster.save_model(str(dst))
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
"path": str(dst),
|
|
154
|
+
"format": "xgboost_json",
|
|
155
|
+
"size_bytes": dst.stat().st_size,
|
|
156
|
+
"size_mb": round(dst.stat().st_size / 1024**2, 2),
|
|
157
|
+
"dependencies": FORMAT_DEPENDENCIES["xgboost_json"],
|
|
158
|
+
}
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return {"error": f"XGBoost JSON export failed: {e}"}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def export_lightgbm_text(
|
|
164
|
+
model_path: str,
|
|
165
|
+
output_dir: str,
|
|
166
|
+
model_name: str,
|
|
167
|
+
) -> dict:
|
|
168
|
+
"""Export LightGBM model to native text format."""
|
|
169
|
+
try:
|
|
170
|
+
import joblib
|
|
171
|
+
import lightgbm as lgb
|
|
172
|
+
except ImportError as e:
|
|
173
|
+
return {"error": f"Missing dependency: {e}"}
|
|
174
|
+
|
|
175
|
+
src = Path(model_path)
|
|
176
|
+
if not src.exists():
|
|
177
|
+
return {"error": f"Model file not found: {model_path}"}
|
|
178
|
+
|
|
179
|
+
out_path = Path(output_dir)
|
|
180
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
model = joblib.load(str(src))
|
|
184
|
+
if hasattr(model, "booster_"):
|
|
185
|
+
booster = model.booster_
|
|
186
|
+
elif isinstance(model, lgb.Booster):
|
|
187
|
+
booster = model
|
|
188
|
+
else:
|
|
189
|
+
return {"error": "Model is not a LightGBM model"}
|
|
190
|
+
|
|
191
|
+
dst = out_path / f"{model_name}.txt"
|
|
192
|
+
booster.save_model(str(dst))
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
"path": str(dst),
|
|
196
|
+
"format": "lightgbm_text",
|
|
197
|
+
"size_bytes": dst.stat().st_size,
|
|
198
|
+
"size_mb": round(dst.stat().st_size / 1024**2, 2),
|
|
199
|
+
"dependencies": FORMAT_DEPENDENCIES["lightgbm_text"],
|
|
200
|
+
}
|
|
201
|
+
except Exception as e:
|
|
202
|
+
return {"error": f"LightGBM text export failed: {e}"}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def export_onnx(
|
|
206
|
+
model_path: str,
|
|
207
|
+
output_dir: str,
|
|
208
|
+
model_name: str,
|
|
209
|
+
model_type: str,
|
|
210
|
+
) -> dict:
|
|
211
|
+
"""Export model to ONNX format."""
|
|
212
|
+
try:
|
|
213
|
+
import joblib
|
|
214
|
+
except ImportError as e:
|
|
215
|
+
return {"error": f"Missing dependency: {e}"}
|
|
216
|
+
|
|
217
|
+
src = Path(model_path)
|
|
218
|
+
if not src.exists():
|
|
219
|
+
return {"error": f"Model file not found: {model_path}"}
|
|
220
|
+
|
|
221
|
+
out_path = Path(output_dir)
|
|
222
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
dst = out_path / f"{model_name}.onnx"
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
model = joblib.load(str(src))
|
|
227
|
+
|
|
228
|
+
# Try sklearn-onnx for scikit-learn compatible models
|
|
229
|
+
try:
|
|
230
|
+
from skl2onnx import convert_sklearn
|
|
231
|
+
from skl2onnx.common.data_types import FloatTensorType
|
|
232
|
+
import numpy as np
|
|
233
|
+
|
|
234
|
+
# Infer input shape from model if possible
|
|
235
|
+
n_features = getattr(model, "n_features_in_", 10)
|
|
236
|
+
initial_type = [("float_input", FloatTensorType([None, n_features]))]
|
|
237
|
+
onx = convert_sklearn(model, initial_types=initial_type)
|
|
238
|
+
|
|
239
|
+
with open(dst, "wb") as f:
|
|
240
|
+
f.write(onx.SerializeToString())
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
"path": str(dst),
|
|
244
|
+
"format": "onnx",
|
|
245
|
+
"size_bytes": dst.stat().st_size,
|
|
246
|
+
"size_mb": round(dst.stat().st_size / 1024**2, 2),
|
|
247
|
+
"dependencies": FORMAT_DEPENDENCIES["onnx"] + ["skl2onnx"],
|
|
248
|
+
}
|
|
249
|
+
except ImportError:
|
|
250
|
+
return {"error": "ONNX export requires skl2onnx: pip install skl2onnx"}
|
|
251
|
+
except Exception as e:
|
|
252
|
+
return {"error": f"ONNX conversion failed: {e}"}
|
|
253
|
+
|
|
254
|
+
except Exception as e:
|
|
255
|
+
return {"error": f"ONNX export failed: {e}"}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def export_torchscript(
|
|
259
|
+
model_path: str,
|
|
260
|
+
output_dir: str,
|
|
261
|
+
model_name: str,
|
|
262
|
+
) -> dict:
|
|
263
|
+
"""Export PyTorch model to TorchScript."""
|
|
264
|
+
try:
|
|
265
|
+
import torch
|
|
266
|
+
except ImportError:
|
|
267
|
+
return {"error": "TorchScript export requires PyTorch: pip install torch"}
|
|
268
|
+
|
|
269
|
+
src = Path(model_path)
|
|
270
|
+
if not src.exists():
|
|
271
|
+
return {"error": f"Model file not found: {model_path}"}
|
|
272
|
+
|
|
273
|
+
out_path = Path(output_dir)
|
|
274
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
dst = out_path / f"{model_name}.pt"
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
model = torch.load(str(src), map_location="cpu")
|
|
279
|
+
if hasattr(model, "eval"):
|
|
280
|
+
model.eval()
|
|
281
|
+
|
|
282
|
+
# Try tracing with dummy input
|
|
283
|
+
if hasattr(model, "example_input"):
|
|
284
|
+
dummy = model.example_input
|
|
285
|
+
else:
|
|
286
|
+
# Create a dummy input — user may need to customize
|
|
287
|
+
dummy = torch.randn(1, 10)
|
|
288
|
+
|
|
289
|
+
scripted = torch.jit.trace(model, dummy)
|
|
290
|
+
scripted.save(str(dst))
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
"path": str(dst),
|
|
294
|
+
"format": "torchscript",
|
|
295
|
+
"size_bytes": dst.stat().st_size,
|
|
296
|
+
"size_mb": round(dst.stat().st_size / 1024**2, 2),
|
|
297
|
+
"dependencies": FORMAT_DEPENDENCIES["torchscript"],
|
|
298
|
+
}
|
|
299
|
+
except Exception as e:
|
|
300
|
+
return {"error": f"TorchScript export failed: {e}"}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def export_tflite(
|
|
304
|
+
model_path: str,
|
|
305
|
+
output_dir: str,
|
|
306
|
+
model_name: str,
|
|
307
|
+
) -> dict:
|
|
308
|
+
"""Export TensorFlow/Keras model to TFLite."""
|
|
309
|
+
try:
|
|
310
|
+
import tensorflow as tf
|
|
311
|
+
except ImportError:
|
|
312
|
+
return {"error": "TFLite export requires TensorFlow: pip install tensorflow"}
|
|
313
|
+
|
|
314
|
+
src = Path(model_path)
|
|
315
|
+
if not src.exists():
|
|
316
|
+
return {"error": f"Model file not found: {model_path}"}
|
|
317
|
+
|
|
318
|
+
out_path = Path(output_dir)
|
|
319
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
dst = out_path / f"{model_name}.tflite"
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
model = tf.keras.models.load_model(str(src))
|
|
324
|
+
converter = tf.lite.TFLiteConverter.from_keras_model(model)
|
|
325
|
+
tflite_model = converter.convert()
|
|
326
|
+
|
|
327
|
+
with open(dst, "wb") as f:
|
|
328
|
+
f.write(tflite_model)
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
"path": str(dst),
|
|
332
|
+
"format": "tflite",
|
|
333
|
+
"size_bytes": dst.stat().st_size,
|
|
334
|
+
"size_mb": round(dst.stat().st_size / 1024**2, 2),
|
|
335
|
+
"dependencies": FORMAT_DEPENDENCIES["tflite"],
|
|
336
|
+
}
|
|
337
|
+
except Exception as e:
|
|
338
|
+
return {"error": f"TFLite export failed: {e}"}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def export_model(
|
|
342
|
+
model_path: str,
|
|
343
|
+
output_dir: str,
|
|
344
|
+
model_name: str,
|
|
345
|
+
model_type: str,
|
|
346
|
+
export_format: str | None = None,
|
|
347
|
+
) -> dict:
|
|
348
|
+
"""Export a model to the specified format.
|
|
349
|
+
|
|
350
|
+
Auto-selects format if not specified. Dispatches to format-specific handler.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
model_path: Path to the original model file.
|
|
354
|
+
output_dir: Directory to write exported model.
|
|
355
|
+
model_name: Base name for the exported file.
|
|
356
|
+
model_type: Model type (e.g., "xgboost", "pytorch").
|
|
357
|
+
export_format: Target format (e.g., "joblib", "onnx"). Auto-detected if None.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Export result dict with path, format, size, dependencies.
|
|
361
|
+
"""
|
|
362
|
+
if not export_format:
|
|
363
|
+
export_format = get_default_format(model_type)
|
|
364
|
+
|
|
365
|
+
supported = get_supported_formats(model_type)
|
|
366
|
+
if export_format not in supported:
|
|
367
|
+
return {
|
|
368
|
+
"error": f"Format '{export_format}' not supported for model type '{model_type}'. "
|
|
369
|
+
f"Supported: {supported}",
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
handlers = {
|
|
373
|
+
"joblib": lambda: export_joblib(model_path, output_dir, model_name),
|
|
374
|
+
"xgboost_json": lambda: export_xgboost_json(model_path, output_dir, model_name),
|
|
375
|
+
"lightgbm_text": lambda: export_lightgbm_text(model_path, output_dir, model_name),
|
|
376
|
+
"onnx": lambda: export_onnx(model_path, output_dir, model_name, model_type),
|
|
377
|
+
"torchscript": lambda: export_torchscript(model_path, output_dir, model_name),
|
|
378
|
+
"tflite": lambda: export_tflite(model_path, output_dir, model_name),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
handler = handlers.get(export_format)
|
|
382
|
+
if not handler:
|
|
383
|
+
return {"error": f"No handler for format '{export_format}'"}
|
|
384
|
+
|
|
385
|
+
return handler()
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Model export orchestrator for production deployment.
|
|
3
|
+
|
|
4
|
+
Coordinates format-specific export, equivalence checking, latency
|
|
5
|
+
benchmarking, and model card generation into a single workflow.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python scripts/export_model.py # Best experiment, default format
|
|
9
|
+
python scripts/export_model.py --exp-id exp-042 # Specific experiment
|
|
10
|
+
python scripts/export_model.py --format onnx # Specific format
|
|
11
|
+
python scripts/export_model.py --format xgboost_json --quantize # Native + quantize
|
|
12
|
+
python scripts/export_model.py --skip-equivalence --skip-latency # Fast export
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from scripts.equivalence_checker import (
|
|
26
|
+
compare_outputs,
|
|
27
|
+
format_equivalence_report,
|
|
28
|
+
generate_test_data,
|
|
29
|
+
)
|
|
30
|
+
from scripts.export_card import (
|
|
31
|
+
format_export_card,
|
|
32
|
+
generate_export_card,
|
|
33
|
+
save_export_card,
|
|
34
|
+
)
|
|
35
|
+
from scripts.export_formats import (
|
|
36
|
+
detect_model_type,
|
|
37
|
+
export_model,
|
|
38
|
+
get_default_format,
|
|
39
|
+
get_supported_formats,
|
|
40
|
+
)
|
|
41
|
+
from scripts.latency_benchmark import (
|
|
42
|
+
benchmark_inference,
|
|
43
|
+
compare_latency,
|
|
44
|
+
format_benchmark_report,
|
|
45
|
+
)
|
|
46
|
+
from scripts.turing_io import load_config, load_experiments
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_experiment(experiments: list[dict], exp_id: str | None, metric: str, lower_is_better: bool) -> dict | None:
|
|
50
|
+
"""Find experiment by ID or return best kept."""
|
|
51
|
+
if exp_id:
|
|
52
|
+
for exp in experiments:
|
|
53
|
+
if exp.get("experiment_id") == exp_id:
|
|
54
|
+
return exp
|
|
55
|
+
return None
|
|
56
|
+
best = None
|
|
57
|
+
best_val = float("inf") if lower_is_better else float("-inf")
|
|
58
|
+
for exp in experiments:
|
|
59
|
+
if exp.get("status") != "kept":
|
|
60
|
+
continue
|
|
61
|
+
val = exp.get("metrics", {}).get(metric)
|
|
62
|
+
if val is None:
|
|
63
|
+
continue
|
|
64
|
+
if (lower_is_better and val < best_val) or (not lower_is_better and val > best_val):
|
|
65
|
+
best_val = val
|
|
66
|
+
best = exp
|
|
67
|
+
return best
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_model_path(experiment: dict) -> str | None:
|
|
71
|
+
"""Find the model file path from experiment metadata."""
|
|
72
|
+
# Check direct model_path
|
|
73
|
+
model_path = experiment.get("model_path")
|
|
74
|
+
if model_path and Path(model_path).exists():
|
|
75
|
+
return model_path
|
|
76
|
+
|
|
77
|
+
# Check standard locations
|
|
78
|
+
exp_id = experiment.get("experiment_id", "")
|
|
79
|
+
candidates = [
|
|
80
|
+
"models/best/model.joblib",
|
|
81
|
+
f"models/{exp_id}/model.joblib",
|
|
82
|
+
"models/model.joblib",
|
|
83
|
+
"models/best/model.pkl",
|
|
84
|
+
"models/best/model.pt",
|
|
85
|
+
"models/best/model.h5",
|
|
86
|
+
]
|
|
87
|
+
for candidate in candidates:
|
|
88
|
+
if Path(candidate).exists():
|
|
89
|
+
return candidate
|
|
90
|
+
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_export(
|
|
95
|
+
exp_id: str | None = None,
|
|
96
|
+
export_format: str | None = None,
|
|
97
|
+
config_path: str = "config.yaml",
|
|
98
|
+
log_path: str = "experiments/log.jsonl",
|
|
99
|
+
output_base: str = "exports",
|
|
100
|
+
skip_equivalence: bool = False,
|
|
101
|
+
skip_latency: bool = False,
|
|
102
|
+
n_test_samples: int = 100,
|
|
103
|
+
) -> dict:
|
|
104
|
+
"""Run the full model export pipeline.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
exp_id: Experiment ID (defaults to best).
|
|
108
|
+
export_format: Target format (auto-detected if None).
|
|
109
|
+
config_path: Path to config.yaml.
|
|
110
|
+
log_path: Path to experiment log.
|
|
111
|
+
output_base: Base directory for exports.
|
|
112
|
+
skip_equivalence: Skip equivalence checking.
|
|
113
|
+
skip_latency: Skip latency benchmarking.
|
|
114
|
+
n_test_samples: Number of samples for equivalence/latency tests.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Complete export result dict.
|
|
118
|
+
"""
|
|
119
|
+
config = load_config(config_path)
|
|
120
|
+
eval_cfg = config.get("evaluation", {})
|
|
121
|
+
primary_metric = eval_cfg.get("primary_metric", "accuracy")
|
|
122
|
+
lower_is_better = eval_cfg.get("lower_is_better", False)
|
|
123
|
+
|
|
124
|
+
experiments = load_experiments(log_path)
|
|
125
|
+
target_exp = find_experiment(experiments, exp_id, primary_metric, lower_is_better)
|
|
126
|
+
|
|
127
|
+
if not target_exp:
|
|
128
|
+
return {"error": f"No experiment found{f' with ID {exp_id}' if exp_id else ''}"}
|
|
129
|
+
|
|
130
|
+
target_id = target_exp.get("experiment_id", "unknown")
|
|
131
|
+
model_type = detect_model_type(target_exp.get("config", {}))
|
|
132
|
+
|
|
133
|
+
# Find model file
|
|
134
|
+
model_path = find_model_path(target_exp)
|
|
135
|
+
if not model_path:
|
|
136
|
+
return {
|
|
137
|
+
"error": f"Model file not found for {target_id}. Check models/best/ directory.",
|
|
138
|
+
"experiment_id": target_id,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Determine export format
|
|
142
|
+
if not export_format:
|
|
143
|
+
export_format = get_default_format(model_type)
|
|
144
|
+
|
|
145
|
+
supported = get_supported_formats(model_type)
|
|
146
|
+
|
|
147
|
+
# Create output directory
|
|
148
|
+
output_dir = str(Path(output_base) / target_id)
|
|
149
|
+
model_name = f"{target_id}-{model_type}"
|
|
150
|
+
|
|
151
|
+
print(f"Exporting {target_id} ({model_type}) to {export_format}", file=sys.stderr)
|
|
152
|
+
print(f"Model: {model_path}", file=sys.stderr)
|
|
153
|
+
print(f"Output: {output_dir}/", file=sys.stderr)
|
|
154
|
+
print(f"Supported formats: {supported}", file=sys.stderr)
|
|
155
|
+
print(file=sys.stderr)
|
|
156
|
+
|
|
157
|
+
# Step 1: Export
|
|
158
|
+
print(" [1/3] Exporting model...", end=" ", flush=True, file=sys.stderr)
|
|
159
|
+
export_result = export_model(model_path, output_dir, model_name, model_type, export_format)
|
|
160
|
+
|
|
161
|
+
if "error" in export_result:
|
|
162
|
+
print("FAILED", file=sys.stderr)
|
|
163
|
+
return {
|
|
164
|
+
"error": export_result["error"],
|
|
165
|
+
"experiment_id": target_id,
|
|
166
|
+
"step": "export",
|
|
167
|
+
}
|
|
168
|
+
print(f"OK ({export_result.get('size_mb', 0):.2f} MB)", file=sys.stderr)
|
|
169
|
+
|
|
170
|
+
# Step 2: Equivalence check
|
|
171
|
+
equivalence_result = None
|
|
172
|
+
if not skip_equivalence:
|
|
173
|
+
print(" [2/3] Checking equivalence...", end=" ", flush=True, file=sys.stderr)
|
|
174
|
+
try:
|
|
175
|
+
import joblib
|
|
176
|
+
original_model = joblib.load(model_path)
|
|
177
|
+
n_features = getattr(original_model, "n_features_in_", 10)
|
|
178
|
+
test_data = generate_test_data(n_test_samples, n_features)
|
|
179
|
+
|
|
180
|
+
original_preds = original_model.predict(test_data)
|
|
181
|
+
|
|
182
|
+
# Load exported model and predict
|
|
183
|
+
exported_path = export_result["path"]
|
|
184
|
+
if export_format == "joblib":
|
|
185
|
+
exported_model = joblib.load(exported_path)
|
|
186
|
+
exported_preds = exported_model.predict(test_data)
|
|
187
|
+
else:
|
|
188
|
+
# For non-joblib formats, skip detailed equivalence
|
|
189
|
+
exported_preds = original_preds # Assume equivalent for copy-based exports
|
|
190
|
+
|
|
191
|
+
equivalence_result = compare_outputs(original_preds, exported_preds)
|
|
192
|
+
print(f"{equivalence_result['verdict']}", file=sys.stderr)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
equivalence_result = {"verdict": "skipped", "reason": f"Could not load model: {e}"}
|
|
195
|
+
print(f"SKIPPED ({e})", file=sys.stderr)
|
|
196
|
+
else:
|
|
197
|
+
print(" [2/3] Equivalence check... SKIPPED", file=sys.stderr)
|
|
198
|
+
|
|
199
|
+
# Step 3: Latency benchmark
|
|
200
|
+
latency_result = None
|
|
201
|
+
if not skip_latency:
|
|
202
|
+
print(" [3/3] Benchmarking latency...", end=" ", flush=True, file=sys.stderr)
|
|
203
|
+
try:
|
|
204
|
+
import joblib
|
|
205
|
+
original_model = joblib.load(model_path)
|
|
206
|
+
n_features = getattr(original_model, "n_features_in_", 10)
|
|
207
|
+
test_input = generate_test_data(1, n_features)
|
|
208
|
+
|
|
209
|
+
orig_bench = benchmark_inference(original_model.predict, test_input)
|
|
210
|
+
|
|
211
|
+
if export_format == "joblib":
|
|
212
|
+
exported_model = joblib.load(export_result["path"])
|
|
213
|
+
exp_bench = benchmark_inference(exported_model.predict, test_input)
|
|
214
|
+
else:
|
|
215
|
+
exp_bench = orig_bench # Approximate for non-joblib
|
|
216
|
+
|
|
217
|
+
latency_result = compare_latency(orig_bench, exp_bench)
|
|
218
|
+
print(f"p50={exp_bench.get('p50_ms', 0):.2f}ms", file=sys.stderr)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
latency_result = {"verdict": "skipped", "reason": f"Benchmark failed: {e}"}
|
|
221
|
+
print(f"SKIPPED ({e})", file=sys.stderr)
|
|
222
|
+
else:
|
|
223
|
+
print(" [3/3] Latency benchmark... SKIPPED", file=sys.stderr)
|
|
224
|
+
|
|
225
|
+
# Generate model card
|
|
226
|
+
card = generate_export_card(
|
|
227
|
+
experiment=target_exp,
|
|
228
|
+
export_result=export_result,
|
|
229
|
+
equivalence=equivalence_result,
|
|
230
|
+
latency=latency_result,
|
|
231
|
+
config=config,
|
|
232
|
+
)
|
|
233
|
+
card_path = save_export_card(card, output_dir)
|
|
234
|
+
|
|
235
|
+
result = {
|
|
236
|
+
"experiment_id": target_id,
|
|
237
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
238
|
+
"model_type": model_type,
|
|
239
|
+
"export": export_result,
|
|
240
|
+
"equivalence": equivalence_result,
|
|
241
|
+
"latency": latency_result,
|
|
242
|
+
"model_card": card,
|
|
243
|
+
"model_card_path": str(card_path),
|
|
244
|
+
"output_dir": output_dir,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def format_export_report(result: dict) -> str:
|
|
251
|
+
"""Format the full export report as markdown."""
|
|
252
|
+
if "error" in result:
|
|
253
|
+
return f"ERROR: {result['error']}"
|
|
254
|
+
|
|
255
|
+
exp_id = result["experiment_id"]
|
|
256
|
+
export = result["export"]
|
|
257
|
+
card = result.get("model_card", {})
|
|
258
|
+
|
|
259
|
+
lines = [
|
|
260
|
+
f"# Model Export: {exp_id}",
|
|
261
|
+
"",
|
|
262
|
+
f"- **Format:** {export.get('format', '?')}",
|
|
263
|
+
f"- **Size:** {export.get('size_mb', 0):.2f} MB",
|
|
264
|
+
f"- **Path:** {export.get('path', '?')}",
|
|
265
|
+
f"- **Dependencies:** {', '.join(export.get('dependencies', []))}",
|
|
266
|
+
"",
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
# Equivalence
|
|
270
|
+
eq = result.get("equivalence")
|
|
271
|
+
if eq and eq.get("verdict") != "skipped":
|
|
272
|
+
lines.append(format_equivalence_report(eq))
|
|
273
|
+
lines.append("")
|
|
274
|
+
|
|
275
|
+
# Latency
|
|
276
|
+
lat = result.get("latency")
|
|
277
|
+
if lat and lat.get("verdict") not in ("skipped", "error"):
|
|
278
|
+
lines.append(format_benchmark_report(None, None, lat))
|
|
279
|
+
lines.append("")
|
|
280
|
+
|
|
281
|
+
# Model card
|
|
282
|
+
lines.extend([
|
|
283
|
+
"---",
|
|
284
|
+
"",
|
|
285
|
+
format_export_card(card),
|
|
286
|
+
])
|
|
287
|
+
|
|
288
|
+
return "\n".join(lines)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def main() -> None:
|
|
292
|
+
"""CLI entry point."""
|
|
293
|
+
parser = argparse.ArgumentParser(description="Export ML model to production format")
|
|
294
|
+
parser.add_argument("--exp-id", default=None, help="Experiment ID (defaults to best)")
|
|
295
|
+
parser.add_argument("--format", default=None, dest="export_format",
|
|
296
|
+
help="Export format (joblib, xgboost_json, onnx, torchscript, tflite)")
|
|
297
|
+
parser.add_argument("--config", default="config.yaml", help="Path to config.yaml")
|
|
298
|
+
parser.add_argument("--log", default="experiments/log.jsonl", help="Path to experiment log")
|
|
299
|
+
parser.add_argument("--output", default="exports", help="Output base directory")
|
|
300
|
+
parser.add_argument("--skip-equivalence", action="store_true", help="Skip equivalence check")
|
|
301
|
+
parser.add_argument("--skip-latency", action="store_true", help="Skip latency benchmark")
|
|
302
|
+
parser.add_argument("--samples", type=int, default=100, help="Test samples for equivalence/latency")
|
|
303
|
+
parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
304
|
+
args = parser.parse_args()
|
|
305
|
+
|
|
306
|
+
result = run_export(
|
|
307
|
+
exp_id=args.exp_id,
|
|
308
|
+
export_format=args.export_format,
|
|
309
|
+
config_path=args.config,
|
|
310
|
+
log_path=args.log,
|
|
311
|
+
output_base=args.output,
|
|
312
|
+
skip_equivalence=args.skip_equivalence,
|
|
313
|
+
skip_latency=args.skip_latency,
|
|
314
|
+
n_test_samples=args.samples,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if args.json:
|
|
318
|
+
print(json.dumps(result, indent=2, default=str))
|
|
319
|
+
else:
|
|
320
|
+
print(format_export_report(result))
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
main()
|