poseaudit 0.1.0__py3-none-any.whl

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.
poseaudit/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """Audit a pose model used as a measuring instrument."""
2
+
3
+ from poseaudit._version import __version__
4
+ from poseaudit.audit import AuditResult, Band, NotRead, Reading, audit
5
+ from poseaudit.io import from_supervision, load_coco, load_coco_results, load_yolo
6
+ from poseaudit.measures import Measure, angle, length, ratio, tilt
7
+ from poseaudit.pairing import Pair, Pairing, pair
8
+ from poseaudit.thresholds import ThresholdAgreement
9
+ from poseaudit.types import Dataset, Instance
10
+
11
+ __all__ = [
12
+ "AuditResult",
13
+ "Band",
14
+ "Dataset",
15
+ "Instance",
16
+ "Measure",
17
+ "NotRead",
18
+ "Pair",
19
+ "Pairing",
20
+ "Reading",
21
+ "ThresholdAgreement",
22
+ "__version__",
23
+ "angle",
24
+ "audit",
25
+ "from_supervision",
26
+ "length",
27
+ "load_coco",
28
+ "load_coco_results",
29
+ "load_yolo",
30
+ "pair",
31
+ "ratio",
32
+ "tilt",
33
+ ]
poseaudit/_version.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("poseaudit")
5
+ except PackageNotFoundError: # running from a source tree that was not installed
6
+ __version__ = "0.0.0"
poseaudit/agreement.py ADDED
@@ -0,0 +1,139 @@
1
+ """Agreement statistics between two readings of the same quantity.
2
+
3
+ `t` is the reference (ground truth), `p` the prediction, `e = p - t`.
4
+ """
5
+
6
+ import numpy as np
7
+
8
+ from poseaudit.confidence import Z95
9
+
10
+
11
+ def gain(t: np.ndarray, p: np.ndarray) -> tuple[float, float]:
12
+ """(slope, intercept) of predicted on truth by least squares.
13
+
14
+ A slope of 0.6 means the model reads a 10 degree change as 6. Unbiased
15
+ when the truth is much less noisy than the prediction, the usual case for
16
+ human labels or motion capture against a model.
17
+ """
18
+ if len(t) < 3 or _flat(t):
19
+ return float("nan"), float("nan")
20
+ dt = t - t.mean()
21
+ slope = float(dt @ (p - p.mean()) / (dt @ dt))
22
+ return slope, float(p.mean() - slope * t.mean())
23
+
24
+
25
+ def robust_gain(
26
+ t: np.ndarray, p: np.ndarray, pairs: int = 500_000, seed: int = 0
27
+ ) -> float:
28
+ """Theil-Sen slope of predicted on truth: the median of the slopes between
29
+ pairs of readings, all of them up to about 1000 readings and a random
30
+ `pairs` of them above. A few gross failures move a least-squares gain a lot
31
+ and this one little; so does noise that grows with the value, so a gap
32
+ between the two is not by itself proof of gross failures."""
33
+ n = len(t)
34
+ if n < 3 or _flat(t):
35
+ return float("nan")
36
+ rng = np.random.default_rng(seed)
37
+ if n * (n - 1) // 2 <= pairs:
38
+ i, j = np.triu_indices(n, k=1)
39
+ else:
40
+ i, j = rng.integers(0, n, pairs), rng.integers(0, n, pairs)
41
+ dt = t[j] - t[i]
42
+ keep = dt != 0
43
+ return float(np.median((p[j] - p[i])[keep] / dt[keep]))
44
+
45
+
46
+ def ba_slope(t: np.ndarray, p: np.ndarray) -> float:
47
+ """Slope of the error on the mean of both readings (Bland-Altman).
48
+
49
+ Equal to (var p - var t) / (2 var mean): a test of equal spread, which reads
50
+ as proportional bias only when both readings are about equally noisy. A
51
+ noisier prediction pushes it upward and can hide real compression.
52
+ """
53
+ m = (t + p) / 2
54
+ if len(t) < 3 or np.ptp(m) == 0:
55
+ return float("nan")
56
+ return float(np.polyfit(m, p - t, 1)[0])
57
+
58
+
59
+ def deming(t: np.ndarray, p: np.ndarray, noise_ratio: float) -> float:
60
+ """Slope of predicted on truth when both carry noise, `noise_ratio` being
61
+ var(prediction noise) / var(truth noise). Infinity gives `gain`."""
62
+ if len(t) < 3:
63
+ return float("nan")
64
+ sxx, syy = np.var(t, ddof=1), np.var(p, ddof=1)
65
+ sxy = np.cov(t, p, ddof=1)[0, 1]
66
+ if sxy == 0:
67
+ return float("nan")
68
+ if np.isinf(noise_ratio):
69
+ return float(sxy / sxx)
70
+ a = syy - noise_ratio * sxx
71
+ return float((a + np.sqrt(a * a + 4 * noise_ratio * sxy * sxy)) / (2 * sxy))
72
+
73
+
74
+ def icc_a1(t: np.ndarray, p: np.ndarray) -> float:
75
+ """ICC(A,1): two-way, absolute agreement, single measurement
76
+ (McGraw and Wong 1996)."""
77
+ y = np.column_stack([t, p])
78
+ n, k = y.shape
79
+ if n < 2:
80
+ return float("nan")
81
+ grand = y.mean()
82
+ ss_rows = k * ((y.mean(axis=1) - grand) ** 2).sum()
83
+ ss_cols = n * ((y.mean(axis=0) - grand) ** 2).sum()
84
+ ss_error = ((y - grand) ** 2).sum() - ss_rows - ss_cols
85
+ ms_r = ss_rows / (n - 1)
86
+ ms_c = ss_cols / (k - 1)
87
+ ms_e = ss_error / ((n - 1) * (k - 1))
88
+ denominator = ms_r + (k - 1) * ms_e + k * (ms_c - ms_e) / n
89
+ return float((ms_r - ms_e) / denominator) if denominator else float("nan")
90
+
91
+
92
+ def ccc(t: np.ndarray, p: np.ndarray) -> float:
93
+ """Lin's concordance correlation coefficient."""
94
+ if len(t) < 2:
95
+ return float("nan")
96
+ cov = np.mean((t - t.mean()) * (p - p.mean()))
97
+ denominator = t.var() + p.var() + (t.mean() - p.mean()) ** 2
98
+ return float(2 * cov / denominator) if denominator else float("nan")
99
+
100
+
101
+ def limits(e: np.ndarray) -> tuple[float, float]:
102
+ """Bias +/- 1.96 SD: 95% of errors, if they are roughly normal."""
103
+ if len(e) < 2:
104
+ return float("nan"), float("nan")
105
+ half = Z95 * e.std(ddof=1)
106
+ return float(e.mean() - half), float(e.mean() + half)
107
+
108
+
109
+ def empirical_limits(e: np.ndarray) -> tuple[float, float]:
110
+ """The 2.5th and 97.5th percentiles: no assumption about the shape, which
111
+ matters when a few gross failures make the tails heavy."""
112
+ if len(e) < 2:
113
+ return float("nan"), float("nan")
114
+ low, high = np.percentile(e, [2.5, 97.5])
115
+ return float(low), float(high)
116
+
117
+
118
+ def repeated_limits(e: np.ndarray, clusters: np.ndarray) -> tuple[float, float]:
119
+ """Limits for several readings per subject, the true value varying within
120
+ a subject (Bland and Altman 2007): between- and within-subject variance
121
+ are added rather than every reading counted as independent."""
122
+ labels, inverse = np.unique(clusters, return_inverse=True)
123
+ counts = np.bincount(inverse)
124
+ n, groups = len(e), len(labels)
125
+ if groups < 2 or groups == n:
126
+ return limits(e)
127
+ means = np.bincount(inverse, weights=e) / counts
128
+ grand = e.mean()
129
+ ms_between = (counts * (means - grand) ** 2).sum() / (groups - 1)
130
+ ms_within = ((e - means[inverse]) ** 2).sum() / (n - groups)
131
+ divisor = (n * n - (counts**2).sum()) / ((groups - 1) * n)
132
+ between = max(0.0, (ms_between - ms_within) / divisor)
133
+ half = Z95 * np.sqrt(between + ms_within)
134
+ return float(grand - half), float(grand + half)
135
+
136
+
137
+ def _flat(t: np.ndarray) -> bool:
138
+ """No spread to fit a slope on: a range that is only rounding noise."""
139
+ return bool(np.ptp(t) <= 1e-9 * max(1.0, float(np.abs(t).max())))