whassan's picture
Deploy Sensorflow Studio
9762da0 verified
Raw History Blame Contribute Delete
13.6 kB
"""Neuro-symbolic semantic scenario mining (lightweight, two-stage).
Industry concept: natural-language scenario search over fleet data (Voxel51 /
Scale Nucleus semantic search, Applied Intuition scenario mining). Two stages:
1. SYMBOLIC filter — structured attributes (container ODD dimensions, error
rates, dynamics) restrict the candidate set exactly.
2. REASONING scorer — if a local Ollama endpoint is reachable (reusing the
copilot plumbing in sensorflow/evaluation/copilot.py) it contributes a
natural-language rationale for the top candidates; otherwise (and always,
for the actual ranking) a DETERMINISTIC rule-based scorer maps concept
keywords to structured evidence via an explicit lexicon. Every result
carries per-stage explanations of why it matched.
Hybrid retrieval: the rule score is blended with cosine similarity over the
run's existing 32-d structural container embeddings (centroid of the top rule
matches acts as the query anchor), combining symbolic and dense signals.
Honest marker: the "reasoning" stage is a keyword lexicon over structured
evidence unless Ollama is reachable; rankings are deterministic either way
(the LLM adds rationale text, it never reorders results).
"""
from __future__ import annotations
import re
from typing import Dict, List, Optional, Tuple
import numpy as np
from sensorflow.megaeval import population as pop_mod
from sensorflow.megaeval.population import DIMENSIONS
# Each lexicon entry: (regex over the concept text, human-readable meaning,
# scorer(row) -> (score in [0,1], evidence string)).
_LEXICON: List[Tuple[str, str, object]] = []
def _rule(pattern: str, meaning: str):
def deco(fn):
_LEXICON.append((pattern, meaning, fn))
return fn
return deco
@_rule(r"pedestrian|vru|vulnerable|person|cyclist|bike", "vulnerable road users present")
def _vru(row):
share = row["safety_n"] / max(row["n_objects"], 1)
return min(1.0, share * 4.0), f"safety-critical VRU share {share:.0%}"
@_rule(r"night|dark|glare|low light", "night / low-illumination context")
def _night(row):
if row["lighting"] == "night":
return 1.0, "lighting=night"
if row["lighting"] == "dusk":
return 0.5, "lighting=dusk"
return 0.0, ""
@_rule(r"rain|wet|fog|snow|weather|storm", "adverse weather context")
def _weather(row):
return (1.0, f"weather={row['weather']}") if row["weather"] != "clear" else (0.0, "")
@_rule(r"hesitant|erratic|unusual|anomal|unpredict|jerky", "anomalous / erratic behavior evidence")
def _anom(row):
rate = row["anomalies"] / max(row["n_objects"], 1)
return min(1.0, rate * 8.0), f"anomaly rate {rate:.0%}"
@_rule(r"occlu|hidden|blocked|obstruct", "heavy occlusion context")
def _occl(row):
return (1.0, "scenario=occlusion_heavy") if row["scenario"] == "occlusion_heavy" else (0.0, "")
@_rule(r"near[ -]?miss|close call|conflict|collision|ttc", "near-miss / extreme-TTC scenario")
def _nearmiss(row):
if row["scenario"] in ("near_miss", "extreme_ttc"):
return 1.0, f"scenario={row['scenario']}"
return 0.0, ""
@_rule(r"intersection|junction|crossing", "intersection scenery")
def _intersection(row):
return (1.0, "road_type=intersection") if row["road_type"] == "intersection" else (0.0, "")
@_rule(r"urban|city|street", "urban scenery")
def _urban(row):
return (1.0, "road_type=urban") if row["road_type"] == "urban" else (0.0, "")
@_rule(r"highway|freeway|motorway", "highway scenery")
def _highway(row):
return (1.0, "road_type=highway") if row["road_type"] == "highway" else (0.0, "")
@_rule(r"rural|country", "rural scenery")
def _rural(row):
return (1.0, "road_type=rural") if row["road_type"] == "rural" else (0.0, "")
@_rule(r"lane edge|lane|curb|edge|merge", "lane-boundary interaction (proxy)")
def _lane(row):
# Proxy: the population has no lane geometry; urban/intersection scenes are
# where lane-edge interactions concentrate. Explained as a proxy.
if row["road_type"] in ("urban", "intersection"):
return 0.5, f"road_type={row['road_type']} (lane-edge proxy — no lane geometry instrumented)"
return 0.0, ""
@_rule(r"miss|fail|undetected|false negative|fn\b", "model misses (FN) evidence")
def _fn(row):
rate = row["fn"] / max(row["n_objects"], 1)
return min(1.0, rate * 5.0), f"FN rate {rate:.0%}"
@_rule(r"false positive|ghost|hallucinat|fp\b", "false-positive evidence")
def _fp(row):
rate = row["fp"] / max(row["n_objects"], 1)
return min(1.0, rate * 5.0), f"FP rate {rate:.0%}"
@_rule(r"sensor|degraded|dropout|noise", "sensor-degraded scenario")
def _sensor(row):
return (1.0, "scenario=sensor_degraded") if row["scenario"] == "sensor_degraded" else (0.0, "")
@_rule(r"risk|critical|danger|safety|severe", "high composite risk")
def _risk(row):
return float(row["risk_score"]), f"risk_score {row['risk_score']:.2f}"
def _container_rows(store, run) -> List[Dict]:
art = store.artifacts(run.run_id)
df = art["containers"]
rows = []
for _, r in df.iterrows():
rows.append({
"container_id": int(r["container_id"]),
**{dim: DIMENSIONS[dim][int(r[dim])] for dim in pop_mod.CONTAINER_DIMS},
"n_objects": int(r["n_objects"]),
"fn": int(r["fn"]), "fp": int(r["fp"]),
"anomalies": int(r["anomalies"]),
"safety_n": int(r["safety_n"]),
"risk_score": float(r["risk_score"]),
})
return rows
def _symbolic_filter(rows: List[Dict], filters: Optional[Dict]) -> Tuple[List[Dict], Dict]:
before = len(rows)
applied = {}
for dim, values in (filters or {}).items():
if dim not in pop_mod.CONTAINER_DIMS:
continue
vals = values if isinstance(values, list) else [values]
rows = [r for r in rows if r[dim] in vals]
applied[dim] = vals
return rows, {"stage": "symbolic_filter", "applied_filters": applied,
"candidates_before": before, "candidates_after": len(rows)}
def _rule_score(concept: str, row: Dict) -> Tuple[float, List[Dict]]:
matched = []
total = 0.0
weight_sum = 0.0
for pattern, meaning, fn in _LEXICON:
if not re.search(pattern, concept, flags=re.IGNORECASE):
continue
score, evidence = fn(row)
weight_sum += 1.0
total += score
if score > 0:
matched.append({"term": pattern, "meaning": meaning,
"evidence": evidence, "contribution": round(score, 4)})
if weight_sum == 0:
return float(row["risk_score"]), [{
"term": "(fallback)", "meaning": "no lexicon term matched the concept",
"evidence": f"ranked by composite risk_score {row['risk_score']:.2f}",
"contribution": round(float(row["risk_score"]), 4)}]
return total / weight_sum, matched
def _llm_rationale(concept: str, top_rows: List[Dict], timeout_s: float = 2.0) -> Optional[Dict]:
"""Optional Ollama pass (copilot plumbing); never reorders results."""
try:
import json as _json
import httpx
from sensorflow.evaluation.copilot import OLLAMA_ENDPOINTS
prompt = (
"You are a scenario-mining assistant. Concept query: "
f"{concept!r}. Candidate scenes (structured evidence):\n"
f"{_json.dumps(top_rows[:6], indent=1)[:2500]}\n"
"In 3 sentences, explain which candidates best match the concept and why."
)
for ep in OLLAMA_ENDPOINTS:
try:
res = httpx.post(ep["url"], json={
"model": ep["model"],
"messages": [{"role": "user", "content": prompt}],
"stream": False,
}, timeout=timeout_s)
if res.status_code == 200:
text = res.json().get("message", {}).get("content", "")
if text:
return {"provider": ep["url"], "rationale": text}
except Exception:
continue
except Exception:
pass
return None
def search_containers(store, run, concept: str, filters: Optional[Dict] = None,
k: int = 12, use_llm: Optional[bool] = None) -> Dict:
"""Two-stage neuro-symbolic search over a run's containers.
Ranking = 0.65 * deterministic rule score + 0.35 * embedding similarity to
the centroid of the top rule matches (hybrid retrieval). LLM (if enabled
and reachable) adds rationale text only.
"""
rows = _container_rows(store, run)
rows, stage1 = _symbolic_filter(rows, filters)
scored = []
for row in rows:
score, matched = _rule_score(concept, row)
scored.append((score, row, matched))
scored.sort(key=lambda t: (-t[0], t[1]["container_id"]))
# Hybrid: embedding similarity to the centroid of the top rule matches.
art = store.artifacts(run.run_id)
emb_ids, emb = art.get("emb_ids"), art.get("emb")
emb_sim: Dict[int, float] = {}
if emb is not None and scored:
anchor_ids = [t[1]["container_id"] for t in scored[:10] if t[0] > 0]
pos = np.where(np.isin(emb_ids, anchor_ids))[0]
if pos.size:
centroid = emb[pos].mean(axis=0)
norm = np.linalg.norm(centroid)
if norm > 1e-9:
sims = emb @ (centroid / norm)
emb_sim = {int(cid): float(s) for cid, s in zip(emb_ids, sims)}
results = []
for rule_score, row, matched in scored:
sim = emb_sim.get(row["container_id"], 0.0)
sim01 = (sim + 1.0) / 2.0
final = 0.65 * rule_score + 0.35 * sim01 if emb_sim else rule_score
results.append({
**row,
"score": round(final, 4),
"rule_score": round(rule_score, 4),
"embedding_similarity": round(sim, 4) if emb_sim else None,
"explanations": {
"stage1_symbolic": ("passed structured filters"
if stage1["applied_filters"] else "no filters applied"),
"stage2_reasoning": matched,
},
})
results.sort(key=lambda r: (-r["score"], r["container_id"]))
results = results[:max(1, min(k, 100))]
llm = None
if use_llm or use_llm is None:
# auto mode: short-timeout attempt; deterministic ranking is unaffected
if use_llm: # explicit request gets a slightly longer timeout
llm = _llm_rationale(concept, results, timeout_s=6.0)
else:
llm = _llm_rationale(concept, results, timeout_s=1.5)
return {
"concept": concept,
"run_id": run.run_id,
"target": "containers",
"stage1": stage1,
"stage2": {
"stage": "reasoning_scorer",
"provider": (llm or {}).get("provider", "offline_deterministic"),
"note": "deterministic lexicon over structured evidence; LLM (when "
"reachable) adds rationale text and never reorders results",
},
"hybrid": {"rule_weight": 0.65, "embedding_weight": 0.35,
"embedding_source": "32-d structural container embeddings"},
"llm_rationale": (llm or {}).get("rationale"),
"results": results,
}
def search_scenarios(concept: str, filters: Optional[Dict] = None, k: int = 12) -> Dict:
"""Same two-stage idea over the scenario database (odd_tags as attributes)."""
from sensorflow.safety.scenario_db import get_db
records = get_db().search(limit=1000, odd_tags=None)
stage1_before = len(records)
filters = filters or {}
for key, val in filters.items():
vals = val if isinstance(val, list) else [val]
records = [r for r in records
if r.odd_tags.get(key) in vals or getattr(r, key, None) in vals]
results = []
for r in records:
row = { # adapt scenario record to the lexicon's row shape
"lighting": r.odd_tags.get("lighting", ""),
"weather": r.odd_tags.get("weather", ""),
"road_type": r.odd_tags.get("road_type", ""),
"scenario": r.scenario_type,
"n_objects": 1, "fn": 0, "fp": 0, "anomalies": 0,
"safety_n": 1 if r.odd_tags.get("actor_class") in
("pedestrian", "cyclist", "motorcycle") else 0,
"risk_score": {"low": 0.2, "medium": 0.45, "high": 0.7,
"critical": 0.95}.get(r.severity, 0.4),
}
score, matched = _rule_score(concept, row)
text_bonus = 0.15 if re.search(r"\w", concept) and any(
w in r.description.lower() for w in re.findall(r"[a-z]{4,}", concept.lower())) else 0.0
results.append({
"scenario_id": r.scenario_id,
"scenario_type": r.scenario_type,
"source": r.source,
"severity": r.severity,
"odd_tags": r.odd_tags,
"description": r.description,
"score": round(min(1.0, score + text_bonus), 4),
"explanations": {"stage1_symbolic": filters or "no filters applied",
"stage2_reasoning": matched},
})
results.sort(key=lambda x: (-x["score"], x["scenario_id"]))
return {
"concept": concept,
"target": "scenarios",
"stage1": {"stage": "symbolic_filter", "applied_filters": filters,
"candidates_before": stage1_before, "candidates_after": len(results)},
"stage2": {"stage": "reasoning_scorer", "provider": "offline_deterministic"},
"results": results[:max(1, min(k, 100))],
}