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
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
|