fix: reject unprovenanced ML artifacts
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""Validation for persisted ML scoring artifacts."""
|
||||
|
||||
import math
|
||||
|
||||
from scoring.policy import SCORE_VERSION
|
||||
|
||||
ML_ARTIFACT_SCHEMA_VERSION = 2
|
||||
REQUIRED_WEIGHT_KEYS = frozenset({
|
||||
"puell_multiple",
|
||||
"mvrv_zscore",
|
||||
"reserve_risk",
|
||||
"rhodl_ratio",
|
||||
"nupl",
|
||||
"fear_greed",
|
||||
"drawdown",
|
||||
"pct_above_200w_sma",
|
||||
"pct_above_lth_rp",
|
||||
})
|
||||
|
||||
|
||||
def _weights_valid(weights):
|
||||
if not isinstance(weights, dict) or not REQUIRED_WEIGHT_KEYS.issubset(weights):
|
||||
return False
|
||||
values = [weights[key] for key in REQUIRED_WEIGHT_KEYS]
|
||||
return all(
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(value)
|
||||
and value >= 0
|
||||
for value in values
|
||||
) and sum(values) > 0
|
||||
|
||||
|
||||
def _has_oos_fold_weights(artifact):
|
||||
folds = artifact.get("cv_results", {}).get("folds", [])
|
||||
if not isinstance(folds, list) or not folds:
|
||||
return False
|
||||
for fold in folds:
|
||||
validation_range = fold.get("date_ranges", {}).get("validation")
|
||||
if not validation_range or not _weights_valid(fold.get("weights")):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_ml_artifact(artifact):
|
||||
"""Return machine-readable validity and provenance for an ML artifact."""
|
||||
errors = []
|
||||
if not isinstance(artifact, dict):
|
||||
artifact = {}
|
||||
errors.append("artifact_object")
|
||||
|
||||
schema_version = artifact.get("artifact_schema_version")
|
||||
if schema_version != ML_ARTIFACT_SCHEMA_VERSION:
|
||||
errors.append("artifact_schema_version")
|
||||
|
||||
score_version = artifact.get("score_version")
|
||||
if score_version != SCORE_VERSION:
|
||||
errors.append("score_version")
|
||||
|
||||
if not _weights_valid(artifact.get("weights")):
|
||||
errors.append("weights")
|
||||
|
||||
provenance = artifact.get("provenance")
|
||||
if not isinstance(provenance, dict):
|
||||
provenance = {}
|
||||
errors.append("provenance")
|
||||
else:
|
||||
required_provenance = {
|
||||
"validation_method",
|
||||
"label_horizon_days",
|
||||
"weight_scope",
|
||||
"training_date_range",
|
||||
"trained_at",
|
||||
}
|
||||
if not required_provenance.issubset(provenance):
|
||||
errors.append("provenance")
|
||||
if provenance.get("validation_method") != "purged_expanding_window":
|
||||
errors.append("purged_validation")
|
||||
if provenance.get("label_horizon_days") != 365:
|
||||
errors.append("label_horizon_days")
|
||||
if provenance.get("weight_scope") != "full_history_fit":
|
||||
errors.append("weight_scope")
|
||||
|
||||
return {
|
||||
"valid": not errors,
|
||||
"schema_version": schema_version,
|
||||
"score_version": score_version,
|
||||
"weight_scope": provenance.get("weight_scope"),
|
||||
"has_oos_fold_weights": _has_oos_fold_weights(artifact),
|
||||
"errors": list(dict.fromkeys(errors)),
|
||||
}
|
||||
+18
-3
@@ -26,7 +26,8 @@ from sklearn.metrics import (
|
||||
from sklearn.model_selection import TimeSeriesSplit
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
from scoring.policy import SCORE_BRACKETS, score_in_bracket
|
||||
from scoring.policy import SCORE_BRACKETS, SCORE_VERSION, score_in_bracket
|
||||
from ml.artifacts import ML_ARTIFACT_SCHEMA_VERSION
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -478,8 +479,12 @@ def train_model(rows):
|
||||
comparison = run_comparison(rows, weights)
|
||||
out_of_sample_comparison = run_out_of_sample_comparison(labeled, fold_results)
|
||||
|
||||
# Build output
|
||||
# Build output. Final weights are fitted on all labeled history for live use;
|
||||
# only the fold weights below are valid for OOS comparisons.
|
||||
trained_at = datetime.now(tz=__import__('datetime').timezone.utc).isoformat()
|
||||
result = {
|
||||
"artifact_schema_version": ML_ARTIFACT_SCHEMA_VERSION,
|
||||
"score_version": SCORE_VERSION,
|
||||
"weights": weights,
|
||||
"feature_importances": {name: round(float(imp), 6) for name, imp in feat_imp},
|
||||
"cv_results": {
|
||||
@@ -501,9 +506,19 @@ def train_model(rows):
|
||||
"date_range": f"{labeled[0]['date']} to {labeled[-1]['date']}",
|
||||
"model": "GradientBoostingClassifier",
|
||||
},
|
||||
"provenance": {
|
||||
"validation_method": "purged_expanding_window",
|
||||
"label_horizon_days": LABEL_HORIZON_DAYS,
|
||||
"weight_scope": "full_history_fit",
|
||||
"training_date_range": {
|
||||
"start": labeled[0]["date"],
|
||||
"end": labeled[-1]["date"],
|
||||
},
|
||||
"trained_at": trained_at,
|
||||
},
|
||||
"comparison": comparison,
|
||||
"out_of_sample_comparison": out_of_sample_comparison,
|
||||
"trained_at": datetime.now(tz=__import__('datetime').timezone.utc).isoformat(),
|
||||
"trained_at": trained_at,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user