robot-data-audit 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/PKG-INFO +1 -1
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/pyproject.toml +1 -1
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/__init__.py +1 -1
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/audit/dataset_audit.py +23 -2
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/calibration/__init__.py +14 -2
- robot_data_audit-0.2.2/rda/calibration/normalization.py +178 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/calibration/portable.py +9 -0
- robot_data_audit-0.2.2/rda/calibration/reference.py +247 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/calibration/scorer.py +79 -47
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/cli/main.py +174 -1
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/robot_data_audit.egg-info/PKG-INFO +1 -1
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/tests/test_calibration.py +327 -19
- robot_data_audit-0.2.0/rda/calibration/normalization.py +0 -126
- robot_data_audit-0.2.0/rda/calibration/reference.py +0 -97
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/LICENSE +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/README.md +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/audit/__init__.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/audit/episode_audit.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/audit/rules.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/cli/__init__.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/io/__init__.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/io/lerobot_loader.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/io/schema.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/metrics/__init__.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/metrics/base.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/metrics/distribution.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/metrics/integrity.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/metrics/motion.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/metrics/temporal.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/report/__init__.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/report/aggregation.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/report/json_report.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/report/summary.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/rda/report/top_issues.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/robot_data_audit.egg-info/SOURCES.txt +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/robot_data_audit.egg-info/dependency_links.txt +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/robot_data_audit.egg-info/entry_points.txt +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/robot_data_audit.egg-info/requires.txt +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/robot_data_audit.egg-info/top_level.txt +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/setup.cfg +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/tests/test_audit.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/tests/test_metrics.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/tests/test_p0_metrics.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/tests/test_p1_metrics.py +0 -0
- {robot_data_audit-0.2.0 → robot_data_audit-0.2.2}/tests/test_phase4_report.py +0 -0
|
@@ -6,6 +6,7 @@ from typing import Dict, List, Optional
|
|
|
6
6
|
|
|
7
7
|
from rda.audit.episode_audit import EpisodeAuditResult, EpisodeAuditor
|
|
8
8
|
from rda.audit.rules import AuditVerdict
|
|
9
|
+
from rda.calibration.reference import ReferenceProfile
|
|
9
10
|
from rda.io.schema import DatasetInfo
|
|
10
11
|
|
|
11
12
|
|
|
@@ -37,19 +38,39 @@ class DatasetAuditResult:
|
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
class DatasetAuditor:
|
|
40
|
-
"""Orchestrates the full audit of a dataset, episode by episode.
|
|
41
|
+
"""Orchestrates the full audit of a dataset, episode by episode.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
episode_auditor: EpisodeAuditor instance to use for each episode.
|
|
45
|
+
If None, a default EpisodeAuditor is created.
|
|
46
|
+
reference: Optional :class:`ReferenceProfile` for behavioral scoring.
|
|
47
|
+
When provided and no ``episode_auditor`` is given, a
|
|
48
|
+
:class:`~rda.calibration.BehavioralScorer` is automatically
|
|
49
|
+
created and passed to the EpisodeAuditor.
|
|
50
|
+
"""
|
|
41
51
|
|
|
42
52
|
def __init__(
|
|
43
53
|
self,
|
|
44
54
|
episode_auditor: Optional[EpisodeAuditor] = None,
|
|
55
|
+
reference: Optional[ReferenceProfile] = None,
|
|
45
56
|
) -> None:
|
|
46
57
|
"""Initialize the dataset auditor.
|
|
47
58
|
|
|
48
59
|
Args:
|
|
49
60
|
episode_auditor: EpisodeAuditor instance to use for each episode.
|
|
50
61
|
If None, a default EpisodeAuditor with all metrics is created.
|
|
62
|
+
reference: Optional ReferenceProfile for behavioral scoring.
|
|
63
|
+
When provided and episode_auditor is None, a BehavioralScorer
|
|
64
|
+
is automatically created and injected into the EpisodeAuditor.
|
|
51
65
|
"""
|
|
52
|
-
|
|
66
|
+
if episode_auditor is not None:
|
|
67
|
+
self.episode_auditor = episode_auditor
|
|
68
|
+
elif reference is not None:
|
|
69
|
+
from rda.calibration.scorer import BehavioralScorer
|
|
70
|
+
scorer = BehavioralScorer(reference)
|
|
71
|
+
self.episode_auditor = EpisodeAuditor(scorer=scorer)
|
|
72
|
+
else:
|
|
73
|
+
self.episode_auditor = EpisodeAuditor()
|
|
53
74
|
|
|
54
75
|
def audit_dataset(
|
|
55
76
|
self,
|
|
@@ -22,8 +22,17 @@ Example::
|
|
|
22
22
|
"""
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
|
-
from rda.calibration.portable import
|
|
26
|
-
|
|
25
|
+
from rda.calibration.portable import (
|
|
26
|
+
METRIC_DIRECTIONS,
|
|
27
|
+
PORTABLE_METRICS,
|
|
28
|
+
extract_portable_metrics,
|
|
29
|
+
)
|
|
30
|
+
from rda.calibration.reference import (
|
|
31
|
+
MetricStats,
|
|
32
|
+
ReferenceProfile,
|
|
33
|
+
load_reference,
|
|
34
|
+
save_reference,
|
|
35
|
+
)
|
|
27
36
|
from rda.calibration.scorer import BehavioralScore, BehavioralScorer, calibrate
|
|
28
37
|
|
|
29
38
|
__all__ = [
|
|
@@ -33,5 +42,8 @@ __all__ = [
|
|
|
33
42
|
"BehavioralScorer",
|
|
34
43
|
"calibrate",
|
|
35
44
|
"PORTABLE_METRICS",
|
|
45
|
+
"METRIC_DIRECTIONS",
|
|
36
46
|
"extract_portable_metrics",
|
|
47
|
+
"save_reference",
|
|
48
|
+
"load_reference",
|
|
37
49
|
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""MAD-based normalization utilities.
|
|
2
|
+
|
|
3
|
+
Implements robust z-score normalization using Median Absolute Deviation
|
|
4
|
+
(MAD) instead of standard deviation, which is less sensitive to outliers
|
|
5
|
+
in robot trajectory data.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Dict, List, Tuple
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def mad(values: np.ndarray) -> float:
|
|
15
|
+
"""Compute Median Absolute Deviation of a 1-D array.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
values: 1-D array of numeric values.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
MAD value. Returns 0.0 for empty arrays.
|
|
22
|
+
"""
|
|
23
|
+
if values.size == 0:
|
|
24
|
+
return 0.0
|
|
25
|
+
med = float(np.median(values))
|
|
26
|
+
return float(np.median(np.abs(values - med)))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def percentile_stats(values: np.ndarray) -> Dict[str, float]:
|
|
30
|
+
"""Compute p05, p25, median, p75, p95 for a 1-D array.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
values: 1-D array of numeric values.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Dict with keys ``p05``, ``p25``, ``median``, ``p75``, ``p95``.
|
|
37
|
+
All zeros for empty input.
|
|
38
|
+
"""
|
|
39
|
+
if values.size == 0:
|
|
40
|
+
return {"p05": 0.0, "p25": 0.0, "median": 0.0, "p75": 0.0, "p95": 0.0}
|
|
41
|
+
pcts = np.percentile(values, [5, 25, 50, 75, 95])
|
|
42
|
+
return {
|
|
43
|
+
"p05": float(pcts[0]),
|
|
44
|
+
"p25": float(pcts[1]),
|
|
45
|
+
"median": float(pcts[2]),
|
|
46
|
+
"p75": float(pcts[3]),
|
|
47
|
+
"p95": float(pcts[4]),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def robust_z_scores(values: np.ndarray) -> np.ndarray:
|
|
52
|
+
"""Compute robust (MAD-based) z-scores for a 1-D array.
|
|
53
|
+
|
|
54
|
+
Scaled by 0.6745 so that the result is comparable to standard
|
|
55
|
+
z-scores for normally distributed data.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
values: 1-D array of raw values.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Array of robust z-scores with same shape as input.
|
|
62
|
+
All zeros if MAD is zero.
|
|
63
|
+
"""
|
|
64
|
+
if values.size == 0:
|
|
65
|
+
return np.array([], dtype=np.float64)
|
|
66
|
+
med = float(np.median(values))
|
|
67
|
+
m = mad(values)
|
|
68
|
+
if m == 0.0:
|
|
69
|
+
return np.zeros_like(values, dtype=np.float64)
|
|
70
|
+
return 0.6745 * (values - med) / m
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def bad_z_scores(values: np.ndarray, direction: str) -> np.ndarray:
|
|
74
|
+
"""Compute direction-aware bad z-scores for a 1-D array.
|
|
75
|
+
|
|
76
|
+
For "higher_is_worse" metrics, positive z-scores indicate anomalies
|
|
77
|
+
(higher values = more anomalous), so bad_z = raw_z.
|
|
78
|
+
For "lower_is_worse" metrics, negative z-scores indicate anomalies
|
|
79
|
+
(lower values = more anomalous), so bad_z = -raw_z.
|
|
80
|
+
|
|
81
|
+
After this transformation, positive bad_z always means "more anomalous"
|
|
82
|
+
regardless of the original metric direction.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
values: 1-D array of raw values.
|
|
86
|
+
direction: One of ``"higher_is_worse"`` or ``"lower_is_worse"``.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Array of bad z-scores with same shape as input.
|
|
90
|
+
All zeros if MAD is zero.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: If direction is not a recognized value.
|
|
94
|
+
"""
|
|
95
|
+
raw_z = robust_z_scores(values)
|
|
96
|
+
if direction == "higher_is_worse":
|
|
97
|
+
return raw_z
|
|
98
|
+
elif direction == "lower_is_worse":
|
|
99
|
+
return -raw_z
|
|
100
|
+
else:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Unknown direction '{direction}'. "
|
|
103
|
+
"Must be 'higher_is_worse' or 'lower_is_worse'."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def compute_pca_loadings(
|
|
108
|
+
z_matrix: np.ndarray,
|
|
109
|
+
metric_names: List[str],
|
|
110
|
+
) -> Tuple[List[Dict[str, float]], List[float]]:
|
|
111
|
+
"""Compute principal component loadings from a z-score matrix.
|
|
112
|
+
|
|
113
|
+
Returns up to min(2, n_features) components for use in behavioral
|
|
114
|
+
scoring. The first component captures the dominant anomaly direction,
|
|
115
|
+
while the second captures orthogonal variance.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
z_matrix: Array of shape ``(n_episodes, n_metrics)`` containing
|
|
119
|
+
robust z-scores (already normalized and direction-adjusted).
|
|
120
|
+
metric_names: List of metric names in column order.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Tuple of ``(components_list, explained_variance_ratios)`` where:
|
|
124
|
+
- ``components_list`` is a list of dicts, each mapping metric names
|
|
125
|
+
to their loading weights for that component.
|
|
126
|
+
- ``explained_variance_ratios`` is a list of floats indicating
|
|
127
|
+
how much variance each component explains.
|
|
128
|
+
Returns empty lists if PCA cannot be computed.
|
|
129
|
+
"""
|
|
130
|
+
n_samples, n_features = z_matrix.shape
|
|
131
|
+
if n_samples < 2 or n_features < 2:
|
|
132
|
+
return [], []
|
|
133
|
+
|
|
134
|
+
# Use SVD-based PCA on the covariance matrix for numerical stability.
|
|
135
|
+
# Center the data (z-scores are already roughly centered, but
|
|
136
|
+
# centering explicitly is correct regardless).
|
|
137
|
+
X = z_matrix - np.mean(z_matrix, axis=0)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
# Full SVD: U (n×n), S (min(n,m)), Vt (m×m)
|
|
141
|
+
U, S, Vt = np.linalg.svd(X, full_matrices=False)
|
|
142
|
+
except np.linalg.LinAlgError:
|
|
143
|
+
return [], []
|
|
144
|
+
|
|
145
|
+
if S.size == 0 or S[0] == 0.0:
|
|
146
|
+
return [], []
|
|
147
|
+
|
|
148
|
+
# Explained variance ratio: singular values squared / sum of all
|
|
149
|
+
total_variance = float(np.sum(S ** 2))
|
|
150
|
+
if total_variance == 0.0:
|
|
151
|
+
return [], []
|
|
152
|
+
|
|
153
|
+
# Number of components to return: min(2, n_features)
|
|
154
|
+
n_components = min(2, n_features)
|
|
155
|
+
|
|
156
|
+
components_list: List[Dict[str, float]] = []
|
|
157
|
+
explained_variance_ratios: List[float] = []
|
|
158
|
+
|
|
159
|
+
for i in range(n_components):
|
|
160
|
+
if i >= len(S) or S[i] == 0.0:
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
evr_i = float((S[i] ** 2) / total_variance)
|
|
164
|
+
explained_variance_ratios.append(evr_i)
|
|
165
|
+
|
|
166
|
+
# Loadings = right singular vector Vt[i]
|
|
167
|
+
loadings = Vt[i].copy()
|
|
168
|
+
# Normalize sign so the largest absolute loading is positive
|
|
169
|
+
max_idx = int(np.argmax(np.abs(loadings)))
|
|
170
|
+
if loadings[max_idx] < 0:
|
|
171
|
+
loadings = -loadings
|
|
172
|
+
|
|
173
|
+
loadings_dict: Dict[str, float] = {}
|
|
174
|
+
for name, val in zip(metric_names, loadings):
|
|
175
|
+
loadings_dict[name] = float(val)
|
|
176
|
+
components_list.append(loadings_dict)
|
|
177
|
+
|
|
178
|
+
return components_list, explained_variance_ratios
|
|
@@ -35,6 +35,15 @@ PORTABLE_METRICS: tuple[str, ...] = (
|
|
|
35
35
|
"effective_motion_ratio",
|
|
36
36
|
)
|
|
37
37
|
|
|
38
|
+
# Direction of "badness" for each portable metric.
|
|
39
|
+
# "higher_is_worse": higher values = more anomalous (e.g. long duration, many spikes)
|
|
40
|
+
# "lower_is_worse": lower values = more anomalous (e.g. low effective motion)
|
|
41
|
+
METRIC_DIRECTIONS: Dict[str, str] = {
|
|
42
|
+
"duration_sec": "higher_is_worse",
|
|
43
|
+
"spike_count": "higher_is_worse",
|
|
44
|
+
"effective_motion_ratio": "lower_is_worse",
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
|
|
39
48
|
def extract_portable_metrics(episode: EpisodeData) -> Dict[str, float]:
|
|
40
49
|
"""Extract the three portable Tier-1 metrics from an episode.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Reference profile data classes for behavioral scoring.
|
|
2
|
+
|
|
3
|
+
A :class:`ReferenceProfile` captures the distribution of portable metrics
|
|
4
|
+
across a calibration population of "normal" episodes. It is used by
|
|
5
|
+
:class:`~rda.calibration.scorer.BehavioralScorer` to compute robust
|
|
6
|
+
deviation scores for incoming episodes.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class MetricStats:
|
|
18
|
+
"""Robust distribution statistics for a single portable metric.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
median: Population median (50th percentile).
|
|
22
|
+
mad: Median Absolute Deviation.
|
|
23
|
+
p05: 5th percentile.
|
|
24
|
+
p25: 25th percentile.
|
|
25
|
+
p75: 75th percentile.
|
|
26
|
+
p95: 95th percentile.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
median: float
|
|
30
|
+
mad: float
|
|
31
|
+
p05: float
|
|
32
|
+
p25: float
|
|
33
|
+
p75: float
|
|
34
|
+
p95: float
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def iqr(self) -> float:
|
|
38
|
+
"""Inter-quartile range (p75 - p25)."""
|
|
39
|
+
return self.p75 - self.p25
|
|
40
|
+
|
|
41
|
+
def robust_z(self, value: float) -> float:
|
|
42
|
+
"""Compute the robust (MAD-based) z-score of *value*.
|
|
43
|
+
|
|
44
|
+
Uses the standard 0.6745 scaling factor for comparability with
|
|
45
|
+
standard z-scores under normality.
|
|
46
|
+
"""
|
|
47
|
+
if self.mad == 0.0:
|
|
48
|
+
return 0.0
|
|
49
|
+
return 0.6745 * (value - self.median) / self.mad
|
|
50
|
+
|
|
51
|
+
def bad_z(self, value: float, direction: str) -> float:
|
|
52
|
+
"""Compute the direction-aware bad z-score of *value*.
|
|
53
|
+
|
|
54
|
+
Positive result always means "more anomalous" regardless of
|
|
55
|
+
the original metric direction.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
value: The raw metric value.
|
|
59
|
+
direction: ``"higher_is_worse"`` or ``"lower_is_worse"``.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Bad z-score. Positive = more anomalous.
|
|
63
|
+
"""
|
|
64
|
+
raw_z = self.robust_z(value)
|
|
65
|
+
if direction == "lower_is_worse":
|
|
66
|
+
return -raw_z
|
|
67
|
+
return raw_z
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> Dict[str, float]:
|
|
70
|
+
"""Serialize to a plain dict."""
|
|
71
|
+
return {
|
|
72
|
+
"median": self.median,
|
|
73
|
+
"mad": self.mad,
|
|
74
|
+
"p05": self.p05,
|
|
75
|
+
"p25": self.p25,
|
|
76
|
+
"p75": self.p75,
|
|
77
|
+
"p95": self.p95,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_dict(cls, data: Dict[str, float]) -> "MetricStats":
|
|
82
|
+
"""Deserialize from a dict."""
|
|
83
|
+
return cls(
|
|
84
|
+
median=data["median"],
|
|
85
|
+
mad=data["mad"],
|
|
86
|
+
p05=data["p05"],
|
|
87
|
+
p25=data["p25"],
|
|
88
|
+
p75=data["p75"],
|
|
89
|
+
p95=data["p95"],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def percentile_rank(self, value: float) -> float:
|
|
93
|
+
"""Approximate percentile rank of *value* via piecewise linear
|
|
94
|
+
interpolation across stored percentiles. Returns value in [0, 1]."""
|
|
95
|
+
anchors = [
|
|
96
|
+
(0.05, self.p05),
|
|
97
|
+
(0.25, self.p25),
|
|
98
|
+
(0.50, self.median),
|
|
99
|
+
(0.75, self.p75),
|
|
100
|
+
(0.95, self.p95),
|
|
101
|
+
]
|
|
102
|
+
if value <= anchors[0][1]:
|
|
103
|
+
return 0.05
|
|
104
|
+
if value >= anchors[-1][1]:
|
|
105
|
+
return 0.95
|
|
106
|
+
for i in range(len(anchors) - 1):
|
|
107
|
+
p_lo, v_lo = anchors[i]
|
|
108
|
+
p_hi, v_hi = anchors[i + 1]
|
|
109
|
+
if v_lo <= value <= v_hi:
|
|
110
|
+
if v_hi == v_lo:
|
|
111
|
+
return p_lo
|
|
112
|
+
frac = (value - v_lo) / (v_hi - v_lo)
|
|
113
|
+
return p_lo + frac * (p_hi - p_lo)
|
|
114
|
+
return 0.5
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class ReferenceProfile:
|
|
119
|
+
"""Reference distribution for behavioral scoring.
|
|
120
|
+
|
|
121
|
+
Built by :func:`rda.calibration.scorer.calibrate` from a set of
|
|
122
|
+
calibration episodes.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
dataset_name: str
|
|
126
|
+
platform: str
|
|
127
|
+
n_calibration: int
|
|
128
|
+
metrics: Dict[str, MetricStats] = field(default_factory=dict)
|
|
129
|
+
task_scope: Optional[str] = None
|
|
130
|
+
# New multi-component PCA fields (P0-2)
|
|
131
|
+
pca_components: List[Dict[str, float]] = field(default_factory=list)
|
|
132
|
+
pca_explained_variance_ratios: List[float] = field(default_factory=list)
|
|
133
|
+
pca_n_components: int = 0
|
|
134
|
+
# Backward compatibility: legacy fields kept for old code
|
|
135
|
+
pca_loadings: Optional[Dict[str, float]] = None
|
|
136
|
+
pca_explained_variance: float = 0.0
|
|
137
|
+
|
|
138
|
+
def __post_init__(self) -> None:
|
|
139
|
+
"""Sync legacy fields with new multi-component fields."""
|
|
140
|
+
# If new fields are populated but legacy fields are not, sync them
|
|
141
|
+
if self.pca_components and self.pca_loadings is None:
|
|
142
|
+
self.pca_loadings = self.pca_components[0] if self.pca_components else None
|
|
143
|
+
if self.pca_explained_variance_ratios and self.pca_explained_variance == 0.0:
|
|
144
|
+
self.pca_explained_variance = self.pca_explained_variance_ratios[0]
|
|
145
|
+
# If legacy fields are populated but new fields are not, sync them
|
|
146
|
+
if self.pca_loadings and not self.pca_components:
|
|
147
|
+
self.pca_components = [self.pca_loadings]
|
|
148
|
+
self.pca_n_components = 1
|
|
149
|
+
if self.pca_explained_variance > 0.0 and not self.pca_explained_variance_ratios:
|
|
150
|
+
self.pca_explained_variance_ratios = [self.pca_explained_variance]
|
|
151
|
+
if self.pca_components and self.pca_n_components == 0:
|
|
152
|
+
self.pca_n_components = len(self.pca_components)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def metric_names(self) -> list[str]:
|
|
156
|
+
"""Sorted list of metric names in the profile."""
|
|
157
|
+
return sorted(self.metrics.keys())
|
|
158
|
+
|
|
159
|
+
def has_pca(self) -> bool:
|
|
160
|
+
"""Return True if PCA loadings are available."""
|
|
161
|
+
return bool(self.pca_components) or (
|
|
162
|
+
self.pca_loadings is not None and len(self.pca_loadings) > 0
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def to_dict(self) -> Dict:
|
|
166
|
+
"""Serialize the profile to a JSON-compatible dict."""
|
|
167
|
+
return {
|
|
168
|
+
"dataset_name": self.dataset_name,
|
|
169
|
+
"platform": self.platform,
|
|
170
|
+
"n_calibration": self.n_calibration,
|
|
171
|
+
"task_scope": self.task_scope,
|
|
172
|
+
"metrics": {
|
|
173
|
+
name: stats.to_dict() for name, stats in self.metrics.items()
|
|
174
|
+
},
|
|
175
|
+
"pca_components": self.pca_components,
|
|
176
|
+
"pca_explained_variance_ratios": self.pca_explained_variance_ratios,
|
|
177
|
+
"pca_n_components": self.pca_n_components,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def from_dict(cls, data: Dict) -> "ReferenceProfile":
|
|
182
|
+
"""Deserialize a profile from a dict (e.g. loaded from JSON)."""
|
|
183
|
+
metrics = {
|
|
184
|
+
name: MetricStats.from_dict(stats_data)
|
|
185
|
+
for name, stats_data in data.get("metrics", {}).items()
|
|
186
|
+
}
|
|
187
|
+
return cls(
|
|
188
|
+
dataset_name=data["dataset_name"],
|
|
189
|
+
platform=data["platform"],
|
|
190
|
+
n_calibration=data["n_calibration"],
|
|
191
|
+
metrics=metrics,
|
|
192
|
+
task_scope=data.get("task_scope"),
|
|
193
|
+
pca_components=data.get("pca_components", []),
|
|
194
|
+
pca_explained_variance_ratios=data.get("pca_explained_variance_ratios", []),
|
|
195
|
+
pca_n_components=data.get("pca_n_components", 0),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def save(self, path: str | Path) -> None:
|
|
199
|
+
"""Save the profile to a JSON file.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
path: Destination file path.
|
|
203
|
+
"""
|
|
204
|
+
path = Path(path)
|
|
205
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
207
|
+
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def load(cls, path: str | Path) -> "ReferenceProfile":
|
|
211
|
+
"""Load a profile from a JSON file.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
path: Source file path.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
A :class:`ReferenceProfile` instance.
|
|
218
|
+
"""
|
|
219
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
220
|
+
data = json.load(f)
|
|
221
|
+
return cls.from_dict(data)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def save_reference(profile: ReferenceProfile, path: str | Path) -> None:
|
|
225
|
+
"""Save a reference profile to a JSON file.
|
|
226
|
+
|
|
227
|
+
Convenience function equivalent to ``profile.save(path)``.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
profile: The profile to save.
|
|
231
|
+
path: Destination file path.
|
|
232
|
+
"""
|
|
233
|
+
profile.save(path)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def load_reference(path: str | Path) -> ReferenceProfile:
|
|
237
|
+
"""Load a reference profile from a JSON file.
|
|
238
|
+
|
|
239
|
+
Convenience function equivalent to ``ReferenceProfile.load(path)``.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
path: Source file path.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
A :class:`ReferenceProfile` instance.
|
|
246
|
+
"""
|
|
247
|
+
return ReferenceProfile.load(path)
|