ds-agent-cli 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.
Files changed (67) hide show
  1. package/bin/ds-agent.js +451 -0
  2. package/ds_agent/__init__.py +8 -0
  3. package/package.json +28 -0
  4. package/requirements.txt +126 -0
  5. package/setup.py +35 -0
  6. package/src/__init__.py +7 -0
  7. package/src/_compress_tool_result.py +118 -0
  8. package/src/api/__init__.py +4 -0
  9. package/src/api/app.py +1626 -0
  10. package/src/cache/__init__.py +5 -0
  11. package/src/cache/cache_manager.py +561 -0
  12. package/src/cli.py +2886 -0
  13. package/src/dynamic_prompts.py +281 -0
  14. package/src/orchestrator.py +4799 -0
  15. package/src/progress_manager.py +139 -0
  16. package/src/reasoning/__init__.py +332 -0
  17. package/src/reasoning/business_summary.py +431 -0
  18. package/src/reasoning/data_understanding.py +356 -0
  19. package/src/reasoning/model_explanation.py +383 -0
  20. package/src/reasoning/reasoning_trace.py +239 -0
  21. package/src/registry/__init__.py +3 -0
  22. package/src/registry/tools_registry.py +3 -0
  23. package/src/session_memory.py +448 -0
  24. package/src/session_store.py +370 -0
  25. package/src/storage/__init__.py +19 -0
  26. package/src/storage/artifact_store.py +620 -0
  27. package/src/storage/helpers.py +116 -0
  28. package/src/storage/huggingface_storage.py +694 -0
  29. package/src/storage/r2_storage.py +0 -0
  30. package/src/storage/user_files_service.py +288 -0
  31. package/src/tools/__init__.py +335 -0
  32. package/src/tools/advanced_analysis.py +823 -0
  33. package/src/tools/advanced_feature_engineering.py +708 -0
  34. package/src/tools/advanced_insights.py +578 -0
  35. package/src/tools/advanced_preprocessing.py +549 -0
  36. package/src/tools/advanced_training.py +906 -0
  37. package/src/tools/agent_tool_mapping.py +326 -0
  38. package/src/tools/auto_pipeline.py +420 -0
  39. package/src/tools/autogluon_training.py +1480 -0
  40. package/src/tools/business_intelligence.py +860 -0
  41. package/src/tools/cloud_data_sources.py +581 -0
  42. package/src/tools/code_interpreter.py +390 -0
  43. package/src/tools/computer_vision.py +614 -0
  44. package/src/tools/data_cleaning.py +614 -0
  45. package/src/tools/data_profiling.py +593 -0
  46. package/src/tools/data_type_conversion.py +268 -0
  47. package/src/tools/data_wrangling.py +433 -0
  48. package/src/tools/eda_reports.py +284 -0
  49. package/src/tools/enhanced_feature_engineering.py +241 -0
  50. package/src/tools/feature_engineering.py +302 -0
  51. package/src/tools/matplotlib_visualizations.py +1327 -0
  52. package/src/tools/model_training.py +520 -0
  53. package/src/tools/nlp_text_analytics.py +761 -0
  54. package/src/tools/plotly_visualizations.py +497 -0
  55. package/src/tools/production_mlops.py +852 -0
  56. package/src/tools/time_series.py +507 -0
  57. package/src/tools/tools_registry.py +2133 -0
  58. package/src/tools/visualization_engine.py +559 -0
  59. package/src/utils/__init__.py +42 -0
  60. package/src/utils/error_recovery.py +313 -0
  61. package/src/utils/parallel_executor.py +402 -0
  62. package/src/utils/polars_helpers.py +248 -0
  63. package/src/utils/schema_extraction.py +132 -0
  64. package/src/utils/semantic_layer.py +392 -0
  65. package/src/utils/token_budget.py +411 -0
  66. package/src/utils/validation.py +377 -0
  67. package/src/workflow_state.py +154 -0
@@ -0,0 +1,520 @@
1
+ """
2
+ Model Training Tools
3
+ Tools for training machine learning models and generating reports.
4
+ """
5
+
6
+ import polars as pl
7
+ import numpy as np
8
+ from typing import Dict, Any, List, Optional
9
+ from pathlib import Path
10
+ import sys
11
+ import os
12
+ import joblib
13
+ import json
14
+ import tempfile
15
+
16
+ # Add parent directory to path for imports
17
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+
19
+ # Import artifact store
20
+ try:
21
+ from storage.helpers import save_model_with_store
22
+ ARTIFACT_STORE_AVAILABLE = True
23
+ except ImportError:
24
+ ARTIFACT_STORE_AVAILABLE = False
25
+ print("⚠️ Artifact store not available, using local paths")
26
+
27
+ from sklearn.model_selection import train_test_split
28
+ from sklearn.linear_model import LogisticRegression, Ridge, Lasso
29
+ from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
30
+ from xgboost import XGBClassifier, XGBRegressor
31
+ from lightgbm import LGBMClassifier, LGBMRegressor
32
+ from catboost import CatBoostClassifier, CatBoostRegressor
33
+ from sklearn.metrics import (
34
+ accuracy_score, precision_score, recall_score, f1_score,
35
+ confusion_matrix, classification_report,
36
+ mean_squared_error, mean_absolute_error, r2_score
37
+ )
38
+ import shap
39
+
40
+ try:
41
+ from .visualization_engine import (
42
+ generate_model_performance_plots,
43
+ generate_feature_importance_plot
44
+ )
45
+ VISUALIZATION_AVAILABLE = True
46
+ except ImportError as e:
47
+ VISUALIZATION_AVAILABLE = False
48
+ print(f"⚠️ Visualization engine not available: {e}")
49
+
50
+ from ds_agent.utils.polars_helpers import (
51
+ load_dataframe,
52
+ get_numeric_columns,
53
+ split_features_target,
54
+ )
55
+ from ds_agent.utils.validation import (
56
+ validate_file_exists,
57
+ validate_file_format,
58
+ validate_dataframe,
59
+ validate_column_exists,
60
+ validate_target_column,
61
+ )
62
+
63
+
64
+ def train_baseline_models(file_path: str, target_col: str,
65
+ task_type: str = "auto",
66
+ test_size: float = 0.2,
67
+ random_state: int = 42) -> Dict[str, Any]:
68
+ """
69
+ Train multiple baseline models and compare performance.
70
+
71
+ Args:
72
+ file_path: Path to prepared dataset
73
+ target_col: Name of target column
74
+ task_type: 'classification', 'regression', or 'auto'
75
+ test_size: Proportion for test split
76
+ random_state: Random seed
77
+
78
+ Returns:
79
+ Dictionary with training results and best model
80
+ """
81
+ # Validation
82
+ validate_file_exists(file_path)
83
+ validate_file_format(file_path)
84
+
85
+ # Load data
86
+ df = load_dataframe(file_path)
87
+ validate_dataframe(df)
88
+ validate_column_exists(df, target_col)
89
+
90
+ # Infer task type if auto
91
+ if task_type == "auto":
92
+ task_type = validate_target_column(df, target_col)
93
+
94
+ # Split features and target
95
+ X, y = split_features_target(df, target_col)
96
+
97
+ # Convert to numpy for sklearn
98
+ # Only keep numeric columns for X
99
+ numeric_cols = get_numeric_columns(X)
100
+ if len(numeric_cols) == 0:
101
+ return {
102
+ "status": "error",
103
+ "message": "No numeric features found. Please encode categorical variables first."
104
+ }
105
+
106
+ X_numeric = X.select(numeric_cols)
107
+ X_np = X_numeric.to_numpy()
108
+ y_np = y.to_numpy()
109
+
110
+ # Handle missing values (simple imputation with mean)
111
+ from sklearn.impute import SimpleImputer
112
+ imputer = SimpleImputer(strategy='mean')
113
+ X_np = imputer.fit_transform(X_np)
114
+
115
+ # Train-test split
116
+ X_train, X_test, y_train, y_test = train_test_split(
117
+ X_np, y_np, test_size=test_size, random_state=random_state
118
+ )
119
+
120
+ results = {
121
+ "task_type": task_type,
122
+ "n_features": X_np.shape[1],
123
+ "n_samples": len(X_np),
124
+ "train_size": len(X_train),
125
+ "test_size": len(X_test),
126
+ "feature_names": numeric_cols,
127
+ "models": {}
128
+ }
129
+
130
+ # Train models based on task type
131
+ import sys
132
+ print(f"\n🚀 Training {5 if task_type == 'classification' else 5} baseline models...", flush=True)
133
+ print(f" 📊 Training set: {len(X_train):,} samples × {X_train.shape[1]} features", flush=True)
134
+ print(f" 📊 Test set: {len(X_test):,} samples", flush=True)
135
+ print(f" ⚡ Note: Random Forest excluded to optimize compute resources", flush=True)
136
+ sys.stdout.flush()
137
+
138
+ if task_type == "classification":
139
+ models = {
140
+ "logistic_regression": LogisticRegression(max_iter=1000, random_state=random_state),
141
+ "xgboost": XGBClassifier(n_estimators=100, random_state=random_state, n_jobs=-1),
142
+ "lightgbm": LGBMClassifier(n_estimators=100, random_state=random_state, n_jobs=-1, verbose=-1),
143
+ "catboost": CatBoostClassifier(iterations=100, random_state=random_state, verbose=0, allow_writing_files=False)
144
+ }
145
+
146
+ for idx, (model_name, model) in enumerate(models.items(), 1):
147
+ try:
148
+ # Train
149
+ print(f"\n [{idx}/{len(models)}] Training {model_name}...", flush=True)
150
+ sys.stdout.flush()
151
+ import time
152
+ start_time = time.time()
153
+ model.fit(X_train, y_train)
154
+ elapsed = time.time() - start_time
155
+ print(f" ✓ {model_name} trained in {elapsed:.1f}s", flush=True)
156
+ sys.stdout.flush()
157
+
158
+ # Predict
159
+ y_pred_train = model.predict(X_train)
160
+ y_pred_test = model.predict(X_test)
161
+
162
+ # Metrics
163
+ results["models"][model_name] = {
164
+ "train_metrics": {
165
+ "accuracy": float(accuracy_score(y_train, y_pred_train)),
166
+ "precision": float(precision_score(y_train, y_pred_train, average='weighted', zero_division=0)),
167
+ "recall": float(recall_score(y_train, y_pred_train, average='weighted', zero_division=0)),
168
+ "f1": float(f1_score(y_train, y_pred_train, average='weighted', zero_division=0))
169
+ },
170
+ "test_metrics": {
171
+ "accuracy": float(accuracy_score(y_test, y_pred_test)),
172
+ "precision": float(precision_score(y_test, y_pred_test, average='weighted', zero_division=0)),
173
+ "recall": float(recall_score(y_test, y_pred_test, average='weighted', zero_division=0)),
174
+ "f1": float(f1_score(y_test, y_pred_test, average='weighted', zero_division=0))
175
+ }
176
+ }
177
+
178
+ # Save model using artifact store
179
+ if ARTIFACT_STORE_AVAILABLE:
180
+ model_path = save_model_with_store(
181
+ model_data={
182
+ "model": model,
183
+ "imputer": imputer,
184
+ "feature_names": numeric_cols
185
+ },
186
+ filename=f"{model_name}.pkl",
187
+ metadata={
188
+ "model_name": model_name,
189
+ "task_type": "classification",
190
+ "train_accuracy": float(accuracy_score(y_train, y_pred_train)),
191
+ "test_accuracy": float(accuracy_score(y_test, y_pred_test)),
192
+ "features": numeric_cols
193
+ }
194
+ )
195
+ else:
196
+ model_path = f"./outputs/models/{model_name}.pkl"
197
+ Path(model_path).parent.mkdir(parents=True, exist_ok=True)
198
+ joblib.dump({
199
+ "model": model,
200
+ "imputer": imputer,
201
+ "feature_names": numeric_cols
202
+ }, model_path)
203
+
204
+ results["models"][model_name]["model_path"] = model_path
205
+
206
+ except Exception as e:
207
+ results["models"][model_name] = {
208
+ "status": "error",
209
+ "message": str(e)
210
+ }
211
+
212
+ else: # regression
213
+ models = {
214
+ "ridge": Ridge(random_state=random_state),
215
+ "lasso": Lasso(random_state=random_state),
216
+ "xgboost": XGBRegressor(n_estimators=100, random_state=random_state, n_jobs=-1),
217
+ "lightgbm": LGBMRegressor(n_estimators=100, random_state=random_state, n_jobs=-1, verbose=-1),
218
+ "catboost": CatBoostRegressor(iterations=100, random_state=random_state, verbose=0, allow_writing_files=False)
219
+ }
220
+
221
+ for idx, (model_name, model) in enumerate(models.items(), 1):
222
+ try:
223
+ # Train
224
+ import sys
225
+ print(f"\n [{idx}/{len(models)}] Training {model_name}...", flush=True)
226
+ sys.stdout.flush()
227
+ import time
228
+ start_time = time.time()
229
+ model.fit(X_train, y_train)
230
+ elapsed = time.time() - start_time
231
+ print(f" ✓ {model_name} trained in {elapsed:.1f}s", flush=True)
232
+ sys.stdout.flush()
233
+
234
+ # Predict
235
+ y_pred_train = model.predict(X_train)
236
+ y_pred_test = model.predict(X_test)
237
+
238
+ # Metrics
239
+ results["models"][model_name] = {
240
+ "train_metrics": {
241
+ "mse": float(mean_squared_error(y_train, y_pred_train)),
242
+ "rmse": float(np.sqrt(mean_squared_error(y_train, y_pred_train))),
243
+ "mae": float(mean_absolute_error(y_train, y_pred_train)),
244
+ "r2": float(r2_score(y_train, y_pred_train))
245
+ },
246
+ "test_metrics": {
247
+ "mse": float(mean_squared_error(y_test, y_pred_test)),
248
+ "rmse": float(np.sqrt(mean_squared_error(y_test, y_pred_test))),
249
+ "mae": float(mean_absolute_error(y_test, y_pred_test)),
250
+ "r2": float(r2_score(y_test, y_pred_test))
251
+ }
252
+ }
253
+
254
+ # Save model using artifact store
255
+ if ARTIFACT_STORE_AVAILABLE:
256
+ model_path = save_model_with_store(
257
+ model_data={
258
+ "model": model,
259
+ "imputer": imputer,
260
+ "feature_names": numeric_cols
261
+ },
262
+ filename=f"{model_name}.pkl",
263
+ metadata={
264
+ "model_name": model_name,
265
+ "task_type": "regression",
266
+ "train_r2": float(r2_score(y_train, y_pred_train)),
267
+ "test_r2": float(r2_score(y_test, y_pred_test)),
268
+ "features": numeric_cols
269
+ }
270
+ )
271
+ else:
272
+ model_path = f"./outputs/models/{model_name}.pkl"
273
+ Path(model_path).parent.mkdir(parents=True, exist_ok=True)
274
+ joblib.dump({
275
+ "model": model,
276
+ "imputer": imputer,
277
+ "feature_names": numeric_cols
278
+ }, model_path)
279
+
280
+ results["models"][model_name]["model_path"] = model_path
281
+
282
+ except Exception as e:
283
+ results["models"][model_name] = {
284
+ "status": "error",
285
+ "message": str(e)
286
+ }
287
+
288
+ # Determine best model
289
+ best_model_name = None
290
+ best_score = -float('inf')
291
+
292
+ for model_name, model_results in results["models"].items():
293
+ if "test_metrics" in model_results:
294
+ if task_type == "classification":
295
+ score = model_results["test_metrics"]["f1"]
296
+ else:
297
+ score = model_results["test_metrics"]["r2"]
298
+
299
+ if score > best_score:
300
+ best_score = score
301
+ best_model_name = model_name
302
+
303
+ results["best_model"] = {
304
+ "name": best_model_name,
305
+ "score": best_score,
306
+ "model_path": results["models"][best_model_name]["model_path"] if best_model_name else None
307
+ }
308
+
309
+ # ⚠️ Add guidance for hyperparameter tuning on large datasets
310
+ if results["n_samples"] > 100000:
311
+ # Recommend faster models for large datasets
312
+ fast_models = ["xgboost", "lightgbm"]
313
+ if best_model_name in fast_models:
314
+ results["tuning_recommendation"] = {
315
+ "suggested_model": best_model_name,
316
+ "reason": f"{best_model_name} is optimal for large datasets - fast training and good performance"
317
+ }
318
+ elif best_model_name == "random_forest_legacy": # Disabled for compute optimization
319
+ # Find next best fast model
320
+ fast_model_scores = {name: results["models"][name]["test_metrics"].get("r2" if task_type == "regression" else "f1", 0)
321
+ for name in fast_models if name in results["models"]}
322
+ if fast_model_scores:
323
+ alt_model = max(fast_model_scores, key=fast_model_scores.get)
324
+ alt_score = fast_model_scores[alt_model]
325
+ score_diff = abs(best_score - alt_score)
326
+ if score_diff < 0.05: # Less than 5% difference
327
+ results["tuning_recommendation"] = {
328
+ "suggested_model": alt_model,
329
+ "reason": f"For large datasets, {alt_model} is 5-10x faster than {best_model_name} with similar performance (score difference: {score_diff:.4f})"
330
+ }
331
+
332
+ # Generate visualizations for best model
333
+ if VISUALIZATION_AVAILABLE and best_model_name:
334
+ try:
335
+ print(f"\n🎨 Generating visualizations for {best_model_name}...")
336
+
337
+ # Load best model
338
+ model_data = joblib.dump({
339
+ "model": models[best_model_name],
340
+ "imputer": imputer,
341
+ "feature_names": numeric_cols
342
+ }, f"./outputs/models/{best_model_name}_temp.pkl")
343
+
344
+ # Get predictions for visualization
345
+ best_model = models[best_model_name]
346
+ y_pred_test = best_model.predict(X_test)
347
+ y_pred_proba = None
348
+ if hasattr(best_model, "predict_proba") and task_type == "classification":
349
+ y_pred_proba = best_model.predict_proba(X_test)
350
+
351
+ # Generate model performance plots
352
+ plot_dir = "./outputs/plots/model_performance"
353
+ perf_plots = generate_model_performance_plots(
354
+ y_true=y_test,
355
+ y_pred=y_pred_test,
356
+ y_pred_proba=y_pred_proba,
357
+ task_type=task_type,
358
+ model_name=best_model_name,
359
+ output_dir=plot_dir
360
+ )
361
+ results["performance_plots"] = perf_plots["plot_paths"]
362
+
363
+ # Generate feature importance plot if available
364
+ if hasattr(best_model, "feature_importances_"):
365
+ feature_importance = dict(zip(numeric_cols, best_model.feature_importances_))
366
+ importance_plot = generate_feature_importance_plot(
367
+ feature_importances=feature_importance,
368
+ output_path=f"{plot_dir}/feature_importance_{best_model_name}.png"
369
+ )
370
+ results["feature_importance_plot"] = importance_plot
371
+
372
+ print(f" ✓ Generated {len(perf_plots.get('plot_paths', []))} performance plots")
373
+ results["visualization_generated"] = True
374
+
375
+ except Exception as e:
376
+ print(f" ⚠️ Could not generate visualizations: {str(e)}")
377
+ results["visualization_generated"] = False
378
+ else:
379
+ results["visualization_generated"] = False
380
+
381
+ # Print final summary
382
+ print(f"\n{'='*60}")
383
+ print(f"✅ TRAINING COMPLETE")
384
+ print(f"{'='*60}")
385
+ print(f"📊 Best Model: {best_model_name}")
386
+ if task_type == "regression":
387
+ print(f"📈 Test R²: {best_score:.4f}")
388
+ print(f"📉 Test RMSE: {results['models'][best_model_name]['test_metrics']['rmse']:.4f}")
389
+ else:
390
+ print(f"📈 Test F1: {best_score:.4f}")
391
+ print(f"📉 Test Accuracy: {results['models'][best_model_name]['test_metrics']['accuracy']:.4f}")
392
+ print(f"💾 Model saved: {results['best_model']['model_path']}")
393
+ print(f"{'='*60}\\n")
394
+
395
+ return results
396
+
397
+
398
+ def generate_model_report(model_path: str, test_data_path: str,
399
+ target_col: str, output_path: str) -> Dict[str, Any]:
400
+ """
401
+ Generate comprehensive model evaluation report.
402
+
403
+ Args:
404
+ model_path: Path to saved model file
405
+ test_data_path: Path to test dataset
406
+ target_col: Name of target column
407
+ output_path: Path to save report JSON
408
+
409
+ Returns:
410
+ Dictionary with model report
411
+ """
412
+ # Validation
413
+ validate_file_exists(model_path)
414
+ validate_file_exists(test_data_path)
415
+
416
+ # Load model
417
+ model_data = joblib.load(model_path)
418
+ model = model_data["model"]
419
+ imputer = model_data["imputer"]
420
+ feature_names = model_data["feature_names"]
421
+
422
+ # Load test data
423
+ df = load_dataframe(test_data_path)
424
+ validate_dataframe(df)
425
+ validate_column_exists(df, target_col)
426
+
427
+ # Prepare features
428
+ X = df.select(feature_names)
429
+ y = df[target_col].to_numpy()
430
+ X_np = imputer.transform(X.to_numpy())
431
+
432
+ # Predict
433
+ y_pred = model.predict(X_np)
434
+
435
+ # Determine task type
436
+ if hasattr(model, "predict_proba"):
437
+ task_type = "classification"
438
+ else:
439
+ task_type = "regression"
440
+
441
+ report = {
442
+ "model_path": model_path,
443
+ "task_type": task_type,
444
+ "n_features": len(feature_names),
445
+ "n_samples": len(X_np)
446
+ }
447
+
448
+ # Calculate metrics
449
+ if task_type == "classification":
450
+ report["metrics"] = {
451
+ "accuracy": float(accuracy_score(y, y_pred)),
452
+ "precision": float(precision_score(y, y_pred, average='weighted', zero_division=0)),
453
+ "recall": float(recall_score(y, y_pred, average='weighted', zero_division=0)),
454
+ "f1": float(f1_score(y, y_pred, average='weighted', zero_division=0))
455
+ }
456
+
457
+ # Confusion matrix
458
+ cm = confusion_matrix(y, y_pred)
459
+ report["confusion_matrix"] = cm.tolist()
460
+
461
+ # Classification report
462
+ class_report = classification_report(y, y_pred, output_dict=True, zero_division=0)
463
+ report["classification_report"] = class_report
464
+
465
+ else: # regression
466
+ report["metrics"] = {
467
+ "mse": float(mean_squared_error(y, y_pred)),
468
+ "rmse": float(np.sqrt(mean_squared_error(y, y_pred))),
469
+ "mae": float(mean_absolute_error(y, y_pred)),
470
+ "r2": float(r2_score(y, y_pred))
471
+ }
472
+
473
+ # Feature importance
474
+ if hasattr(model, "feature_importances_"):
475
+ importances = model.feature_importances_
476
+ feature_importance = [
477
+ {"feature": name, "importance": float(imp)}
478
+ for name, imp in zip(feature_names, importances)
479
+ ]
480
+ feature_importance.sort(key=lambda x: x["importance"], reverse=True)
481
+ report["feature_importance"] = feature_importance[:20] # Top 20
482
+
483
+ # SHAP values (for top 10 features)
484
+ try:
485
+ # Use TreeExplainer for tree-based models
486
+ if hasattr(model, "feature_importances_"):
487
+ explainer = shap.TreeExplainer(model)
488
+ else:
489
+ # Use KernelExplainer for other models (sample for speed)
490
+ sample_size = min(100, len(X_np))
491
+ explainer = shap.KernelExplainer(
492
+ model.predict,
493
+ X_np[:sample_size]
494
+ )
495
+
496
+ shap_values = explainer.shap_values(X_np[:100]) # First 100 samples
497
+
498
+ # Calculate mean absolute SHAP values
499
+ if isinstance(shap_values, list): # Multi-class
500
+ shap_values = shap_values[0]
501
+
502
+ mean_shap = np.abs(shap_values).mean(axis=0)
503
+ shap_importance = [
504
+ {"feature": name, "shap_value": float(val)}
505
+ for name, val in zip(feature_names, mean_shap)
506
+ ]
507
+ shap_importance.sort(key=lambda x: x["shap_value"], reverse=True)
508
+ report["shap_feature_importance"] = shap_importance[:10] # Top 10
509
+
510
+ except Exception as e:
511
+ report["shap_error"] = f"Could not compute SHAP values: {str(e)}"
512
+
513
+ # Save report
514
+ Path(output_path).parent.mkdir(parents=True, exist_ok=True)
515
+ with open(output_path, 'w') as f:
516
+ json.dump(report, f, indent=2)
517
+
518
+ report["output_path"] = output_path
519
+
520
+ return report