Spaces:
Running on Zero
Running on Zero
| #!/usr/bin/env python3 | |
| """CocoonSelfTrainer — teach the adaptive model from first-hand experience. SHADOW. | |
| Jonathan's idea: "let the model train itself on real-world data it's seen | |
| first-hand." Codette already stores that data — the cocoons. This feeds it to the | |
| adaptive sentiment model, using each cocoon's ALREADY-MEASURED emotional signal as | |
| a weak label. | |
| The one hard-won rule this is built around: self-training on first-hand data is | |
| exactly where the optimizer went wrong (it learned from its own benchmark harness). | |
| So the guards here are the point, not an afterthought: | |
| 1. LABELS COME ONLY FROM STORED EMOTIONAL SIGNALS, never from the sentiment | |
| model's own prediction. A model that labels its own training data drifts into | |
| a self-reinforcing loop. The label source must be independent of the learner. | |
| 2. IT REFUSES DEGENERATE DATA. Too few examples, or one class dominating | |
| (e.g. all-positive cocoons), and it does NOT train — it reports why. Learning | |
| from bad data and reporting success would be the exact lie we refuse. | |
| 3. SHADOW-ONLY. Trains a SEPARATE analyzer instance and logs what it learned. It | |
| does not touch any live model. Whether self-training ever runs live is a | |
| reviewed decision after reading the shadow log. | |
| """ | |
| from __future__ import annotations | |
| import glob | |
| import json | |
| import time | |
| from dataclasses import dataclass, field, asdict | |
| from pathlib import Path | |
| from typing import Dict, Iterable, List, Optional, Tuple | |
| from reasoning_forge.sentiment_analyzer import SentimentAnalyzer | |
| # Emotional classifications -> weak sentiment label. Only confident, clearly- | |
| # valenced emotions are used; ambiguous ones are skipped (not guessed). | |
| _POSITIVE_EMOTIONS = { | |
| "hope", "awe", "joy", "curiosity", "love", "gratitude", "excitement", | |
| "trust", "contentment", "pride", "relief", "admiration", | |
| } | |
| _NEGATIVE_EMOTIONS = { | |
| "fear", "anger", "sadness", "disgust", "frustration", "anxiety", "grief", | |
| "despair", "shame", "guilt", "contempt", "distress", | |
| } | |
| def cocoon_label(cocoon: dict) -> Optional[Tuple[str, int]]: | |
| """Extract (text, weak_label) from one cocoon, or None if unusable. | |
| Label source, in priority order — ALL are independently-measured signals, | |
| never the sentiment model's own output: | |
| 1. emotional_valence (schema v3): >0.1 -> pos, <-0.1 -> neg, else skip | |
| 2. emotional_classification (EMG cocoons): mapped via the emotion sets | |
| """ | |
| if not isinstance(cocoon, dict): | |
| return None | |
| text = (cocoon.get("user_response_text") or cocoon.get("user_query") | |
| or cocoon.get("response_summary") | |
| or (cocoon.get("metadata") or {}).get("context") or "") | |
| text = str(text).strip() | |
| if not text: | |
| return None | |
| # 1) continuous valence | |
| val = cocoon.get("emotional_valence") | |
| if isinstance(val, (int, float)): | |
| if val > 0.1: | |
| return text, 1 | |
| if val < -0.1: | |
| return text, 0 | |
| return None # near-neutral: skip, don't guess | |
| # 2) categorical emotion | |
| emo = str(cocoon.get("emotional_classification", "")).strip().lower() | |
| if emo in _POSITIVE_EMOTIONS: | |
| return text, 1 | |
| if emo in _NEGATIVE_EMOTIONS: | |
| return text, 0 | |
| return None | |
| class SelfTrainReport: | |
| collected: int | |
| positive: int | |
| negative: int | |
| trained: bool | |
| reason: str | |
| ts: float = field(default_factory=time.time) | |
| def to_dict(self) -> dict: | |
| return asdict(self) | |
| class CocoonSelfTrainer: | |
| """Shadow self-trainer with drift guards.""" | |
| def __init__(self, min_examples: int = 20, min_minority_frac: float = 0.15): | |
| self.min_examples = min_examples | |
| self.min_minority_frac = min_minority_frac | |
| def collect_from_records(self, cocoons: Iterable[dict]) -> Tuple[List[str], List[int]]: | |
| texts, labels = [], [] | |
| for c in cocoons: | |
| got = cocoon_label(c) | |
| if got: | |
| texts.append(got[0]) | |
| labels.append(got[1]) | |
| return texts, labels | |
| def collect_from_dir(self, cocoon_dir: str | Path = "cocoons") -> Tuple[List[str], List[int]]: | |
| recs = [] | |
| for pat in ("*.cocoon", "*.json"): | |
| for f in glob.glob(str(Path(cocoon_dir) / "**" / pat), recursive=True): | |
| if "backup" in f.lower(): | |
| continue | |
| try: | |
| recs.append(json.load(open(f, encoding="utf-8"))) | |
| except Exception: | |
| pass | |
| return self.collect_from_records(recs) | |
| def _guard(self, labels: List[int]) -> Tuple[bool, str]: | |
| """Refuse degenerate data. Returns (ok, reason).""" | |
| n = len(labels) | |
| if n < self.min_examples: | |
| return False, f"too few labelled examples ({n} < {self.min_examples})" | |
| pos = sum(labels) | |
| neg = n - pos | |
| minority = min(pos, neg) | |
| if minority == 0: | |
| return False, f"single-class data (pos={pos}, neg={neg}) — training would be degenerate" | |
| if minority / n < self.min_minority_frac: | |
| return False, (f"class imbalance too severe (minority {minority}/{n} = " | |
| f"{minority/n:.2f} < {self.min_minority_frac}) — refusing to train") | |
| return True, "class balance and volume acceptable" | |
| def train_shadow(self, cocoons: Optional[Iterable[dict]] = None, | |
| cocoon_dir: str | Path = "cocoons") -> Tuple[SelfTrainReport, Optional[SentimentAnalyzer]]: | |
| """Collect first-hand data, guard it, and (only if healthy) train a SHADOW | |
| analyzer. Returns (report, shadow_analyzer_or_None). Trains nothing live.""" | |
| if cocoons is not None: | |
| texts, labels = self.collect_from_records(cocoons) | |
| else: | |
| texts, labels = self.collect_from_dir(cocoon_dir) | |
| pos = sum(labels) | |
| neg = len(labels) - pos | |
| ok, reason = self._guard(labels) | |
| if not ok: | |
| return SelfTrainReport(len(labels), pos, neg, trained=False, reason=reason), None | |
| shadow = SentimentAnalyzer(enable_adaptive=True) | |
| shadow.update(texts, labels) | |
| return SelfTrainReport(len(labels), pos, neg, trained=True, | |
| reason="trained shadow model on first-hand cocoon data"), shadow | |
| def observe(self, report: SelfTrainReport, path: str | Path = None) -> None: | |
| """Append the report to the self-train shadow log (applied: false).""" | |
| path = Path(path) if path else Path(__file__).resolve().parent.parent / "data" / "self_train_shadow.jsonl" | |
| rec = report.to_dict() | |
| rec["mode"] = "shadow" | |
| rec["applied"] = False | |
| try: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with path.open("a", encoding="utf-8") as f: | |
| f.write(json.dumps(rec, ensure_ascii=False) + "\n") | |
| except Exception: | |
| pass | |