MetaDebate / viral_script_engine /retention /curve_predictor.py
vajeeda's picture
feat(phase12): RetentionCurveSimulator, R10, 150-sample dataset, model trained, 14 tests PASS, gate PASS
79cb04a
Raw
History Blame Contribute Delete
5.17 kB
import json
from pathlib import Path
from typing import List, Optional
import numpy as np
from pydantic import BaseModel
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.multioutput import MultiOutputRegressor
import joblib
from viral_script_engine.retention.feature_extractor import FeatureExtractor, ScriptFeatures
_MODEL_PATH = Path(__file__).parent / "model.joblib"
CURVE_TIMEPOINTS = [0, 3, 6, 10, 15, 20, 25, 30, 45, 60]
class RetentionCurve(BaseModel):
timepoints: List[int]
values: List[float]
area_under_curve: float
drop_off_point: int # first timepoint where retention drops below 0.5
@classmethod
def from_values(cls, values: List[float]) -> "RetentionCurve":
tps = CURVE_TIMEPOINTS
# Trapezoidal AUC, normalised to [0, 1]
auc = 0.0
for i in range(len(tps) - 1):
dt = tps[i + 1] - tps[i]
auc += dt * (values[i] + values[i + 1]) / 2
total_duration = tps[-1] - tps[0]
auc = auc / total_duration if total_duration > 0 else 0.0
drop_off = tps[-1]
for t, v in zip(tps, values):
if v < 0.5:
drop_off = t
break
return cls(
timepoints=list(tps),
values=[round(v, 4) for v in values],
area_under_curve=round(auc, 4),
drop_off_point=drop_off,
)
class RetentionCurvePredictor:
"""
Predicts a 10-point retention curve from script features.
10 points = retention at seconds [0, 3, 6, 10, 15, 20, 25, 30, 45, 60].
Uses MultiOutputRegressor(GradientBoostingRegressor).
Lightweight enough to run on CPU without GPU (<1ms per call after training).
"""
MODEL_PATH = _MODEL_PATH
CURVE_TIMEPOINTS = CURVE_TIMEPOINTS
def __init__(self):
if _MODEL_PATH.exists():
self.model = joblib.load(_MODEL_PATH)
self._trained = True
else:
self.model = MultiOutputRegressor(
GradientBoostingRegressor(n_estimators=100, max_depth=4, random_state=42)
)
self._trained = False
def train(
self,
dataset_path: Optional[str] = None,
cultural_kb_path: Optional[str] = None,
) -> dict:
"""
Train on retention_dataset.json. Saves model to MODEL_PATH.
Returns dict with avg_mae and mae_per_timepoint.
"""
if dataset_path is None:
dataset_path = str(
Path(__file__).parent / "training_data" / "retention_dataset.json"
)
with open(dataset_path, "r", encoding="utf-8") as f:
data = json.load(f)
extractor = FeatureExtractor(cultural_kb_path=cultural_kb_path)
X: List[List[float]] = []
y: List[List[float]] = []
skipped = 0
for sample in data["samples"]:
try:
features = extractor.extract(
sample["script_text"], sample["platform"], sample["region"]
)
vec = features.to_vector()
if any(v != v for v in vec): # NaN check
skipped += 1
continue
X.append(vec)
y.append(sample["retention_curve"])
except Exception:
skipped += 1
if not X:
raise RuntimeError("No valid training samples extracted.")
X_arr = np.array(X, dtype=float)
y_arr = np.array(y, dtype=float)
n = len(X_arr)
rng = np.random.RandomState(42)
idx = rng.permutation(n)
split = max(1, int(n * 0.8))
X_train, X_val = X_arr[idx[:split]], X_arr[idx[split:]]
y_train, y_val = y_arr[idx[:split]], y_arr[idx[split:]]
self.model.fit(X_train, y_train)
self._trained = True
val_preds = np.clip(self.model.predict(X_val), 0.0, 1.0)
mae_per_tp = np.mean(np.abs(val_preds - y_val), axis=0).tolist()
avg_mae = float(np.mean(mae_per_tp))
print(f" Trained on {len(X_train)} samples, validated on {len(X_val)} (skipped {skipped})")
print(" Train/Val MAE per timepoint:")
for t, mae in zip(CURVE_TIMEPOINTS, mae_per_tp):
print(f" {t:2d}s: {mae:.4f}")
print(f" Avg MAE: {avg_mae:.4f}")
joblib.dump(self.model, _MODEL_PATH)
print(f" Model saved to {_MODEL_PATH}")
return {"avg_mae": avg_mae, "mae_per_timepoint": mae_per_tp}
def predict(self, features: ScriptFeatures) -> RetentionCurve:
if not self._trained:
raise RuntimeError("Model not trained. Run train() first.")
vec = np.array(features.to_vector(), dtype=float).reshape(1, -1)
raw = self.model.predict(vec)[0]
clipped = np.clip(raw, 0.0, 1.0)
values = self._enforce_monotonic_decrease(clipped).tolist()
return RetentionCurve.from_values(values)
@staticmethod
def _enforce_monotonic_decrease(values: np.ndarray) -> np.ndarray:
result = values.copy()
for i in range(1, len(result)):
result[i] = min(result[i], result[i - 1])
return result