You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

121 lines
3.5 KiB
Python

"""Lightweight learned ranking models for candidate prioritization."""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
def resolve_ranking_model_path(model_path: str) -> Path:
path = Path(model_path)
if path.is_absolute():
return path
return Path.cwd() / path
@lru_cache(maxsize=16)
def load_ranking_model(model_path: str) -> dict[str, Any]:
path = resolve_ranking_model_path(model_path)
return json.loads(path.read_text())
def compute_ranking_model_score(
row: dict[str, Any],
model_path: str | None,
) -> float | None:
if not model_path:
return None
model = load_ranking_model(model_path)
model_type = str(model.get("model_type", "")).lower()
if model_type != "bucket_blend_v1":
return None
features = model.get("features", [])
total_weight = 0.0
weighted_sum = 0.0
for feature in features:
weight = float(feature.get("weight", 0.0))
if weight <= 0:
continue
key = _resolve_feature_key(str(feature.get("name", "")), row)
if key is None:
continue
value = feature.get("values", {}).get(key)
if value is None:
continue
weighted_sum += weight * float(value)
total_weight += weight
if total_weight <= 0:
fallback = model.get("global_mean")
return float(fallback) if fallback is not None else None
return weighted_sum / total_weight
def _resolve_feature_key(name: str, row: dict[str, Any]) -> str | None:
if name == "event_type":
return _safe_text(row.get("event_type"))
if name == "direction_guidance_combo":
direction = _safe_text(row.get("event_direction"))
guidance = _safe_text(row.get("guidance_status"))
if direction is None or guidance is None:
return None
return f"{direction}|{guidance}"
if name == "reaction_bucket":
return _bucketize(
_safe_float(row.get("reaction_day_return")),
[0.03, 0.05, 0.08, 0.12, 0.18, 0.25],
)
if name == "close_bucket":
return _bucketize(
_safe_float(row.get("close_location")),
[0.45, 0.55, 0.65, 0.70, 0.75, 0.83],
)
if name == "volume_bucket":
return _bucketize(
_safe_float(row.get("volume_ratio_20d")),
[1.0, 1.5, 2.0, 3.0, 4.0, 6.0],
)
if name == "gap_bucket":
return _bucketize(
_safe_float(row.get("gap_size")),
[0.0, 0.02, 0.05, 0.08, 0.15],
)
if name == "document_bucket":
return _bucketize(
_safe_float(row.get("document_quality_score")),
[0.60, 0.66, 0.70, 0.75, 0.80, 0.85],
)
if name == "confidence_bucket":
return _bucketize(
_safe_float(row.get("parse_confidence_overall")),
[0.60, 0.64, 0.70, 0.75, 0.80, 0.90],
)
return None
def _bucketize(value: float | None, edges: list[float]) -> str | None:
if value is None or not edges:
return None
if value < edges[0]:
return f"lt:{edges[0]:.4f}"
for low, high in zip(edges, edges[1:]):
if low <= value < high:
return f"{low:.4f}:{high:.4f}"
return f"ge:{edges[-1]:.4f}"
def _safe_float(raw: Any) -> float | None:
try:
return float(raw)
except (TypeError, ValueError):
return None
def _safe_text(raw: Any) -> str | None:
if raw is None:
return None
text = str(raw).strip().lower()
return text or None