-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconfidence.py
More file actions
200 lines (165 loc) · 6.91 KB
/
Copy pathconfidence.py
File metadata and controls
200 lines (165 loc) · 6.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""
Pure abstain / low-confidence helper for the STEP keyword pipeline.
Two pieces of information may be available for each keyword that the system
proposes:
* **rule_hits** — how many regular-expression patterns fired in the
taxonomy module for that keyword. Always ``>= 0``. ``None`` is treated as
"unknown".
* **cosine_score** — the cosine similarity between the problem text and the
keyword in the embedding space (Layer 7 / per-scene similarity output).
Always in ``[-1, 1]`` after L2-normalisation; ``None`` is treated as
"unknown".
The abstain rule (mirroring the equation written in
``pipeline_report.docx`` section 3 (e)) is::
uncertain(k) = ( rule_hits == 0 ) AND ( cosine_score < tau_low )
where unknown signals are treated conservatively: a keyword is *not* marked
uncertain when nothing is known, so the system never silently downgrades a
chip just because a score happens to be missing.
The module is import-safe (no LLM, no network, no file I/O) and ships with
its own self-test that runs when the file is executed directly.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Iterable, Mapping
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class ConfidenceConfig:
"""Thresholds for the three-band keyword confidence display.
* ``tau_low`` cosine below which a rule-less keyword is *uncertain*.
* ``tau_high`` cosine at or above which a keyword is *high* confidence.
* ``min_rule_hits`` minimum regex hits to override a low cosine score.
"""
tau_low: float = 0.30
tau_high: float = 0.50
min_rule_hits: int = 1
def default_confidence_config() -> ConfidenceConfig:
"""Build a config from optional environment overrides (no surprises if unset)."""
def _f(name: str, default: float) -> float:
try:
return float(os.getenv(name, "").strip() or default)
except ValueError:
return default
def _i(name: str, default: int) -> int:
try:
return int(os.getenv(name, "").strip() or default)
except ValueError:
return default
return ConfidenceConfig(
tau_low=_f("STEP_CONFIDENCE_TAU_LOW", 0.30),
tau_high=_f("STEP_CONFIDENCE_TAU_HIGH", 0.50),
min_rule_hits=_i("STEP_CONFIDENCE_MIN_HITS", 1),
)
# ---------------------------------------------------------------------------
# Decision
# ---------------------------------------------------------------------------
def is_uncertain(
rule_hits: int | None,
cosine_score: float | None,
*,
cfg: ConfidenceConfig | None = None,
) -> bool:
"""Return True when the abstain condition holds.
The decision uses the conservative reading: if neither signal is known,
the keyword is *not* flagged. A known rule hit (>= ``min_rule_hits``)
overrides a low cosine score; a known cosine at or above ``tau_low``
overrides zero rule hits.
"""
cfg = cfg or default_confidence_config()
has_rules = isinstance(rule_hits, int) and rule_hits >= cfg.min_rule_hits
if has_rules:
return False
if cosine_score is None:
return False
return cosine_score < cfg.tau_low
def confidence_band(
rule_hits: int | None,
cosine_score: float | None,
*,
cfg: ConfidenceConfig | None = None,
) -> str:
"""Return ``"high"``, ``"medium"`` or ``"low"`` for UI styling."""
cfg = cfg or default_confidence_config()
if is_uncertain(rule_hits, cosine_score, cfg=cfg):
return "low"
if cosine_score is not None and cosine_score >= cfg.tau_high:
return "high"
if isinstance(rule_hits, int) and rule_hits >= cfg.min_rule_hits:
return "high"
return "medium"
# ---------------------------------------------------------------------------
# Bulk annotation (used by run.py / run_video.py)
# ---------------------------------------------------------------------------
def annotate_keywords(
keywords: Iterable[str],
*,
cosine_scores: Mapping[str, float] | None = None,
rule_hits: Mapping[str, int] | None = None,
cfg: ConfidenceConfig | None = None,
) -> list[dict]:
"""Return one ``chip`` dict per keyword with ``score``, ``hits``,
``uncertain`` and ``band`` fields. Order is preserved.
Lookup keys are lowercased internally so callers do not have to worry
about case differences between sources.
"""
cfg = cfg or default_confidence_config()
scores_lc = {str(k).lower(): float(v) for k, v in (cosine_scores or {}).items()}
hits_lc = {str(k).lower(): int(v) for k, v in (rule_hits or {}).items()}
out: list[dict] = []
for raw in keywords:
if not raw:
continue
name = str(raw)
lc = name.lower()
s = scores_lc.get(lc)
h = hits_lc.get(lc)
chip = {
"keyword": name,
"score": (round(s, 6) if isinstance(s, (int, float)) else None),
"hits": (int(h) if isinstance(h, (int, float)) else None),
"uncertain": is_uncertain(h, s, cfg=cfg),
"band": confidence_band(h, s, cfg=cfg),
}
out.append(chip)
return out
# ---------------------------------------------------------------------------
# Self-test
# ---------------------------------------------------------------------------
def _self_test() -> None:
cfg = ConfidenceConfig(tau_low=0.30, tau_high=0.50, min_rule_hits=1)
# Strong cosine → high.
assert confidence_band(0, 0.66, cfg=cfg) == "high"
# Mid cosine, no rules → medium.
assert confidence_band(0, 0.40, cfg=cfg) == "medium"
# Low cosine, no rules → low (uncertain).
assert is_uncertain(0, 0.18, cfg=cfg) is True
assert confidence_band(0, 0.18, cfg=cfg) == "low"
# Rule hit overrides low cosine.
assert is_uncertain(2, 0.10, cfg=cfg) is False
assert confidence_band(2, 0.10, cfg=cfg) == "high"
# Unknown signals never abstain.
assert is_uncertain(None, None, cfg=cfg) is False
assert is_uncertain(None, 0.10, cfg=cfg) is True # cosine known, low → abstain
assert is_uncertain(0, None, cfg=cfg) is False # cosine unknown → no abstain
chips = annotate_keywords(
["Trigonometric substitution", "Volume of revolution", "Total area"],
cosine_scores={
"trigonometric substitution": 0.66,
"volume of revolution": 0.40,
"total area": 0.05,
},
rule_hits={
"trigonometric substitution": 1,
"volume of revolution": 0,
"total area": 0,
},
cfg=cfg,
)
assert chips[0]["band"] == "high" and not chips[0]["uncertain"]
assert chips[1]["band"] == "medium" and not chips[1]["uncertain"]
assert chips[2]["band"] == "low" and chips[2]["uncertain"]
print("confidence self-test OK:", chips)
if __name__ == "__main__":
_self_test()