unitsleuth 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.
unitsleuth/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """unitsleuth: detect mixed measurement units in numeric columns."""
2
+
3
+ from unitsleuth import catalog
4
+ from unitsleuth.catalog import Conversion
5
+ from unitsleuth.detect import UnivariateFit, detect, fit_conversion
6
+ from unitsleuth.multivariate import RowFit, detect_rows, repair
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "Conversion",
12
+ "RowFit",
13
+ "UnivariateFit",
14
+ "catalog",
15
+ "detect",
16
+ "detect_rows",
17
+ "fit_conversion",
18
+ "repair",
19
+ ]
unitsleuth/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from unitsleuth.cli import main
2
+
3
+ raise SystemExit(main())
unitsleuth/_frame.py ADDED
@@ -0,0 +1,50 @@
1
+ """Dataframe adapters built on Narwhals (pandas, Polars, PyArrow, ...)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ import narwhals.stable.v1 as nw
9
+ import numpy as np
10
+ from numpy.typing import ArrayLike, NDArray
11
+
12
+ FloatArray = NDArray[np.float64]
13
+
14
+
15
+ def is_frame(obj: Any) -> bool:
16
+ """True for native eager dataframes that Narwhals understands."""
17
+ if isinstance(obj, Mapping):
18
+ return False
19
+ try:
20
+ nw.from_native(obj, eager_only=True)
21
+ except TypeError:
22
+ return False
23
+ return True
24
+
25
+
26
+ def columns_of(data: Any, names: list[str] | tuple[str, ...]) -> dict[str, ArrayLike]:
27
+ """Extract named columns as float arrays from a mapping or a dataframe."""
28
+ if isinstance(data, Mapping):
29
+ return {n: data[n] for n in names}
30
+ frame = nw.from_native(data, eager_only=True)
31
+ missing = [n for n in names if n not in frame.columns]
32
+ if missing:
33
+ raise KeyError(f"columns not in data: {missing}")
34
+ out: dict[str, ArrayLike] = {}
35
+ for n in names:
36
+ s = frame[n].cast(nw.Float64)
37
+ out[n] = np.array(s.to_list(), dtype=np.float64)
38
+ # to_list maps nulls to None; np.array(..., float64) turns None into nan
39
+ return out
40
+
41
+
42
+ def replace_columns(data: Any, cols: Mapping[str, FloatArray]) -> Any:
43
+ """Return a native dataframe of the same type with ``cols`` replaced."""
44
+ frame = nw.from_native(data, eager_only=True)
45
+ ns = nw.get_native_namespace(frame)
46
+ new = [
47
+ nw.new_series(name, arr.tolist(), nw.Float64, native_namespace=ns)
48
+ for name, arr in cols.items()
49
+ ]
50
+ return frame.with_columns(*new).to_native()
unitsleuth/catalog.py ADDED
@@ -0,0 +1,117 @@
1
+ """Catalog of unit conversions that unitsleuth can test for.
2
+
3
+ Every conversion is affine: ``x_to = scale * x_from + offset``. Multiplicative
4
+ conversions (``offset == 0``) cover most unit pairs; temperature needs the offset.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import numpy as np
12
+ from numpy.typing import ArrayLike, NDArray
13
+
14
+ FloatArray = NDArray[np.float64]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Conversion:
19
+ """An affine map between two units of the same quantity."""
20
+
21
+ quantity: str
22
+ from_unit: str
23
+ to_unit: str
24
+ scale: float
25
+ offset: float = 0.0
26
+
27
+ def __post_init__(self) -> None:
28
+ if not (np.isfinite(self.scale) and np.isfinite(self.offset)):
29
+ raise ValueError("scale and offset must be finite")
30
+ if self.scale == 0.0:
31
+ raise ValueError("scale must be non-zero")
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ """Short label such as ``'kg->lb'``."""
36
+ return f"{self.from_unit}->{self.to_unit}"
37
+
38
+ @property
39
+ def is_multiplicative(self) -> bool:
40
+ """True when the conversion has no additive offset."""
41
+ return self.offset == 0.0
42
+
43
+ def forward(self, x: ArrayLike) -> FloatArray:
44
+ """Convert values expressed in ``from_unit`` to ``to_unit``."""
45
+ arr = np.asarray(x, dtype=np.float64)
46
+ return np.asarray(self.scale * arr + self.offset, dtype=np.float64)
47
+
48
+ def inverse(self, y: ArrayLike) -> FloatArray:
49
+ """Convert values expressed in ``to_unit`` back to ``from_unit``."""
50
+ arr = np.asarray(y, dtype=np.float64)
51
+ return np.asarray((arr - self.offset) / self.scale, dtype=np.float64)
52
+
53
+
54
+ def _c(
55
+ quantity: str, from_unit: str, to_unit: str, scale: float, offset: float = 0.0
56
+ ) -> Conversion:
57
+ return Conversion(quantity, from_unit, to_unit, scale, offset)
58
+
59
+
60
+ _CATALOG: tuple[Conversion, ...] = (
61
+ # length
62
+ _c("length", "m", "ft", 3.280839895013123),
63
+ _c("length", "cm", "in", 1.0 / 2.54),
64
+ _c("length", "km", "mi", 0.621371192237334),
65
+ _c("length", "m", "cm", 100.0),
66
+ _c("length", "ft", "in", 12.0),
67
+ # mass
68
+ _c("mass", "kg", "lb", 2.2046226218487757),
69
+ _c("mass", "kg", "g", 1000.0),
70
+ _c("mass", "lb", "oz", 16.0),
71
+ _c("mass", "g", "oz", 0.035273961949580414),
72
+ # temperature
73
+ _c("temperature", "degC", "degF", 1.8, 32.0),
74
+ _c("temperature", "degC", "K", 1.0, 273.15),
75
+ # volume
76
+ _c("volume", "L", "mL", 1000.0),
77
+ _c("volume", "L", "gal_us", 0.26417205235814845),
78
+ # pressure
79
+ _c("pressure", "kPa", "Pa", 1000.0),
80
+ _c("pressure", "kPa", "psi", 0.14503773773375),
81
+ _c("pressure", "mmHg", "kPa", 0.133322387415),
82
+ # speed
83
+ _c("speed", "km/h", "mph", 0.621371192237334),
84
+ _c("speed", "km/h", "m/s", 1.0 / 3.6),
85
+ # time
86
+ _c("time", "h", "min", 60.0),
87
+ _c("time", "min", "s", 60.0),
88
+ # proportions
89
+ _c("ratio", "fraction", "percent", 100.0),
90
+ # generic power-of-ten scale errors (classic keying/recording mistakes)
91
+ _c("generic", "x1", "x10", 10.0),
92
+ _c("generic", "x1", "x100", 100.0),
93
+ _c("generic", "x1", "x1000", 1000.0),
94
+ )
95
+
96
+
97
+ def conversions(quantity: str | None = None) -> tuple[Conversion, ...]:
98
+ """Return the built-in conversions, optionally filtered by quantity."""
99
+ if quantity is None:
100
+ return _CATALOG
101
+ selected = tuple(c for c in _CATALOG if c.quantity == quantity)
102
+ if not selected:
103
+ raise KeyError(f"unknown quantity {quantity!r}; known: {quantities()}")
104
+ return selected
105
+
106
+
107
+ def quantities() -> tuple[str, ...]:
108
+ """Return the quantities covered by the catalog, in catalog order."""
109
+ return tuple(dict.fromkeys(c.quantity for c in _CATALOG))
110
+
111
+
112
+ def get(from_unit: str, to_unit: str) -> Conversion:
113
+ """Look up a built-in conversion by its unit names."""
114
+ for conv in _CATALOG:
115
+ if conv.from_unit == from_unit and conv.to_unit == to_unit:
116
+ return conv
117
+ raise KeyError(f"no built-in conversion {from_unit}->{to_unit}")
unitsleuth/cli.py ADDED
@@ -0,0 +1,104 @@
1
+ """Command-line interface: ``unitsleuth data.csv --col height:cm,in``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import sys
8
+ from collections.abc import Sequence
9
+
10
+ import numpy as np
11
+
12
+ from unitsleuth import catalog
13
+ from unitsleuth.catalog import Conversion
14
+ from unitsleuth.multivariate import detect_rows, repair
15
+
16
+
17
+ def _resolve(spec: str) -> tuple[str, Conversion]:
18
+ try:
19
+ name, units = spec.rsplit(":", 1)
20
+ a, b = (u.strip() for u in units.split(","))
21
+ except ValueError:
22
+ raise argparse.ArgumentTypeError(
23
+ f"bad --col {spec!r}; expected NAME:FROM,TO such as height:cm,in"
24
+ ) from None
25
+ try:
26
+ return name, catalog.get(a, b)
27
+ except KeyError:
28
+ pass
29
+ try:
30
+ c = catalog.get(b, a)
31
+ except KeyError:
32
+ raise argparse.ArgumentTypeError(
33
+ f"no built-in conversion between {a!r} and {b!r}"
34
+ ) from None
35
+ return name, Conversion(c.quantity, a, b, 1.0 / c.scale, -c.offset / c.scale)
36
+
37
+
38
+ def _num(s: str) -> float:
39
+ try:
40
+ return float(s)
41
+ except ValueError:
42
+ return float("nan")
43
+
44
+
45
+ def build_parser() -> argparse.ArgumentParser:
46
+ p = argparse.ArgumentParser(
47
+ prog="unitsleuth",
48
+ description="Detect rows recorded in a different measurement unit.",
49
+ )
50
+ p.add_argument("csv", help="input CSV file with a header row")
51
+ p.add_argument(
52
+ "--col",
53
+ action="append",
54
+ required=True,
55
+ type=_resolve,
56
+ metavar="NAME:FROM,TO",
57
+ help="column and the conversion to test, e.g. weight:kg,lb (repeatable)",
58
+ )
59
+ p.add_argument("--out", help="write a repaired CSV to this path")
60
+ p.add_argument(
61
+ "--target",
62
+ choices=["majority", "base", "converted"],
63
+ default="majority",
64
+ help="unit to harmonise to when repairing (default: majority)",
65
+ )
66
+ p.add_argument("--min-delta-bic", type=float, default=10.0)
67
+ return p
68
+
69
+
70
+ def main(argv: Sequence[str] | None = None) -> int:
71
+ """Run the CLI. Exit code 1 means mixed units were detected."""
72
+ args = build_parser().parse_args(argv)
73
+ convs = dict(args.col)
74
+ with open(args.csv, newline="", encoding="utf-8") as fh:
75
+ reader = csv.DictReader(fh)
76
+ fields = list(reader.fieldnames or [])
77
+ rows = list(reader)
78
+ missing = [c for c in convs if c not in fields]
79
+ if missing:
80
+ print(f"error: columns not in file: {missing}", file=sys.stderr)
81
+ return 2
82
+ data = {c: np.array([_num(r[c]) for r in rows]) for c in convs}
83
+ try:
84
+ fit = detect_rows(data, convs, min_delta_bic=args.min_delta_bic)
85
+ except ValueError as exc:
86
+ print(f"error: {exc}", file=sys.stderr)
87
+ return 2
88
+ print(fit.summary())
89
+ if args.out:
90
+ fixed = repair(data, fit, target=args.target)
91
+ for i, r in enumerate(rows):
92
+ for c in convs:
93
+ if np.isfinite(fixed[c][i]):
94
+ r[c] = repr(float(fixed[c][i]))
95
+ with open(args.out, "w", newline="", encoding="utf-8") as fh:
96
+ w = csv.DictWriter(fh, fieldnames=fields)
97
+ w.writeheader()
98
+ w.writerows(rows)
99
+ print(f"wrote repaired data to {args.out}")
100
+ return 1 if fit.detected else 0
101
+
102
+
103
+ if __name__ == "__main__":
104
+ raise SystemExit(main())
unitsleuth/detect.py ADDED
@@ -0,0 +1,236 @@
1
+ """Univariate detection of mixed units in a single numeric column.
2
+
3
+ Model (after Di Zio, Guarnera & Luzi, 2005): true values follow a normal
4
+ distribution on a working scale (log for multiplicative conversions of positive
5
+ data, raw otherwise). A fraction ``weight`` of rows was recorded in a different
6
+ unit, i.e. passed through a known conversion. The mixture is fitted by EM and
7
+ compared with the single-unit model through BIC.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Literal
14
+
15
+ import numpy as np
16
+ from numpy.typing import ArrayLike, NDArray
17
+
18
+ from unitsleuth import catalog
19
+ from unitsleuth.catalog import Conversion
20
+
21
+ FloatArray = NDArray[np.float64]
22
+ BoolArray = NDArray[np.bool_]
23
+ Direction = Literal["forward", "inverse"]
24
+ Scale = Literal["log", "linear"]
25
+
26
+ _LOG_2PI = float(np.log(2.0 * np.pi))
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class UnivariateFit:
31
+ """Result of testing one conversion against one column."""
32
+
33
+ conversion: Conversion
34
+ direction: Direction
35
+ scale: Scale
36
+ weight: float
37
+ mu: float
38
+ sigma: float
39
+ loglik: float
40
+ loglik_null: float
41
+ delta_bic: float
42
+ detected: bool
43
+ posterior: FloatArray
44
+
45
+ @property
46
+ def converted(self) -> BoolArray:
47
+ """Rows whose posterior probability of being converted exceeds 0.5."""
48
+ with np.errstate(invalid="ignore"):
49
+ return np.asarray(self.posterior > 0.5, dtype=np.bool_)
50
+
51
+ @property
52
+ def label(self) -> str:
53
+ """Human-readable description of the hypothesis, e.g. ``'cm->in'``."""
54
+ if self.direction == "forward":
55
+ return self.conversion.name
56
+ return f"{self.conversion.to_unit}->{self.conversion.from_unit}"
57
+
58
+
59
+ def _normal_logpdf(
60
+ t: FloatArray, mu: FloatArray | float, sigma: FloatArray | float
61
+ ) -> FloatArray:
62
+ z = (t - mu) / sigma
63
+ return np.asarray(-0.5 * z * z - np.log(sigma) - 0.5 * _LOG_2PI, dtype=np.float64)
64
+
65
+
66
+ def _fit_mixture(
67
+ t: FloatArray, shift: float, mult: float, jac: float, max_iter: int, tol: float
68
+ ) -> tuple[float, float, float, float]:
69
+ """EM for tied-parameter two-component model.
70
+
71
+ Converted rows are pulled back with ``y = (t - shift) / mult`` and then share
72
+ ``mu`` and ``sigma`` with the base rows. ``jac`` is the log-density Jacobian
73
+ of the converted component. Returns ``(weight, mu, sigma, loglik)``.
74
+ """
75
+ n = t.size
76
+ y = (t - shift) / mult
77
+ sd_all = float(np.std(t))
78
+ floor = 1e-3 * sd_all if sd_all > 0 else 1e-9
79
+ med = float(np.median(t))
80
+ mad = 1.4826 * float(np.median(np.abs(t - med)))
81
+ sig0 = max(mad / 2.0, floor)
82
+ best: tuple[float, float, float, float] | None = None
83
+ for mu0 in np.quantile(t, [0.25, 0.5, 0.75]):
84
+ for w0 in (0.05, 0.2, 0.4):
85
+ mu, sigma, w = float(mu0), sig0, w0
86
+ prev = -np.inf
87
+ ll = -np.inf
88
+ for _ in range(max_iter):
89
+ a = np.log1p(-w) + _normal_logpdf(t, mu, sigma)
90
+ b = np.log(w) + _normal_logpdf(y, mu, sigma) + jac
91
+ m = np.maximum(a, b)
92
+ lse = m + np.log(np.exp(a - m) + np.exp(b - m))
93
+ ll = float(lse.sum())
94
+ r = np.exp(b - lse)
95
+ w = float(np.clip(r.mean(), 1e-6, 1 - 1e-6))
96
+ mu = float(np.sum((1 - r) * t + r * y) / n)
97
+ var = float(np.sum((1 - r) * (t - mu) ** 2 + r * (y - mu) ** 2) / n)
98
+ sigma = max(np.sqrt(var), floor)
99
+ if abs(ll - prev) < tol * (1.0 + abs(ll)):
100
+ break
101
+ prev = ll
102
+ if best is None or ll > best[3]:
103
+ best = (w, mu, sigma, ll)
104
+ assert best is not None
105
+ return best
106
+
107
+
108
+ def _params(conv: Conversion, direction: Direction) -> tuple[float, float]:
109
+ if direction == "forward":
110
+ return conv.scale, conv.offset
111
+ return 1.0 / conv.scale, -conv.offset / conv.scale
112
+
113
+
114
+ def fit_conversion(
115
+ x: ArrayLike,
116
+ conversion: Conversion,
117
+ *,
118
+ direction: Direction | None = None,
119
+ scale: Scale | None = None,
120
+ min_delta_bic: float = 10.0,
121
+ min_weight: float = 0.005,
122
+ min_n: int = 10,
123
+ max_iter: int = 500,
124
+ tol: float = 1e-10,
125
+ ) -> UnivariateFit:
126
+ """Test whether a share of ``x`` was recorded in a different unit.
127
+
128
+ ``direction=None`` fits both ``from->to`` and ``to->from`` and keeps the better
129
+ one. ``scale=None`` uses the log scale for multiplicative conversions of
130
+ strictly positive data and the raw scale otherwise.
131
+ """
132
+ arr = np.asarray(x, dtype=np.float64).ravel()
133
+ ok = np.isfinite(arr)
134
+ xs = arr[ok]
135
+ if xs.size < min_n:
136
+ raise ValueError(f"need at least {min_n} finite values, got {xs.size}")
137
+ if direction is None:
138
+ fits = [
139
+ fit_conversion(
140
+ arr,
141
+ conversion,
142
+ direction=d,
143
+ scale=scale,
144
+ min_delta_bic=min_delta_bic,
145
+ min_weight=min_weight,
146
+ min_n=min_n,
147
+ max_iter=max_iter,
148
+ tol=tol,
149
+ )
150
+ for d in ("forward", "inverse")
151
+ ]
152
+ return max(fits, key=lambda f: f.loglik)
153
+
154
+ a, c = _params(conversion, direction)
155
+ can_log = c == 0.0 and a > 0.0 and bool(np.all(xs > 0))
156
+ if scale is None:
157
+ scale = "log" if can_log else "linear"
158
+ if scale == "log" and not can_log:
159
+ raise ValueError(
160
+ "log scale needs a positive multiplicative conversion and x > 0"
161
+ )
162
+
163
+ if scale == "log":
164
+ t = np.log(xs)
165
+ shift, mult, jac = float(np.log(a)), 1.0, 0.0
166
+ else:
167
+ t = xs
168
+ shift, mult, jac = c, a, -float(np.log(abs(a)))
169
+ if float(np.std(t)) == 0.0:
170
+ post = np.full(arr.shape, np.nan)
171
+ post[ok] = 0.0
172
+ return UnivariateFit(
173
+ conversion,
174
+ direction,
175
+ scale,
176
+ 0.0,
177
+ float(t[0]),
178
+ 0.0,
179
+ 0.0,
180
+ 0.0,
181
+ 0.0,
182
+ False,
183
+ post,
184
+ )
185
+
186
+ w, mu, sigma, ll = _fit_mixture(t, shift, mult, jac, max_iter, tol)
187
+
188
+ mu0 = float(np.mean(t))
189
+ sig0 = float(np.std(t))
190
+ ll0 = float(_normal_logpdf(t, mu0, sig0).sum())
191
+ n = t.size
192
+ delta_bic = (-2.0 * ll0 + 2 * np.log(n)) - (-2.0 * ll + 3 * np.log(n))
193
+
194
+ la = np.log1p(-w) + _normal_logpdf(t, mu, sigma)
195
+ lb = np.log(w) + _normal_logpdf((t - shift) / mult, mu, sigma) + jac
196
+ r = 1.0 / (1.0 + np.exp(np.clip(la - lb, -700, 700)))
197
+ post = np.full(arr.shape, np.nan)
198
+ post[ok] = r
199
+ return UnivariateFit(
200
+ conversion=conversion,
201
+ direction=direction,
202
+ scale=scale,
203
+ weight=w,
204
+ mu=mu,
205
+ sigma=sigma,
206
+ loglik=ll,
207
+ loglik_null=ll0,
208
+ delta_bic=float(delta_bic),
209
+ detected=bool(delta_bic >= min_delta_bic and w >= min_weight),
210
+ posterior=post,
211
+ )
212
+
213
+
214
+ def detect(
215
+ x: ArrayLike,
216
+ conversions: list[Conversion] | tuple[Conversion, ...] | None = None,
217
+ *,
218
+ quantity: str | None = None,
219
+ **kwargs: float | int,
220
+ ) -> list[UnivariateFit]:
221
+ """Test several conversions and return fits sorted by evidence (best first).
222
+
223
+ Pass ``conversions`` explicitly, or a catalog ``quantity`` such as ``"mass"``;
224
+ by default every catalog conversion is tried. Extra keyword arguments go to
225
+ :func:`fit_conversion`.
226
+ """
227
+ cands = (
228
+ tuple(conversions) if conversions is not None else catalog.conversions(quantity)
229
+ )
230
+ out: list[UnivariateFit] = []
231
+ for conv in cands:
232
+ try:
233
+ out.append(fit_conversion(x, conv, **kwargs)) # type: ignore[arg-type]
234
+ except ValueError:
235
+ continue
236
+ return sorted(out, key=lambda f: f.delta_bic, reverse=True)
@@ -0,0 +1,247 @@
1
+ """Row-consistent detection across several columns, plus repair.
2
+
3
+ Each row carries one latent flag: either every column is in its base unit, or
4
+ every column was recorded in the converted unit (for example a whole record
5
+ entered in imperial units). The flag is shared by all observed columns of the
6
+ row, so a row with a clear height gives evidence about its ambiguous weight.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+ from typing import Any, Literal
14
+
15
+ import numpy as np
16
+ from numpy.typing import ArrayLike, NDArray
17
+
18
+ from unitsleuth._frame import columns_of, is_frame, replace_columns
19
+ from unitsleuth.catalog import Conversion
20
+ from unitsleuth.detect import Scale, _normal_logpdf
21
+
22
+ FloatArray = NDArray[np.float64]
23
+ BoolArray = NDArray[np.bool_]
24
+ Target = Literal["majority", "base", "converted"]
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RowFit:
29
+ """Joint fit of one shared row flag over several columns."""
30
+
31
+ columns: tuple[str, ...]
32
+ conversions: dict[str, Conversion]
33
+ scales: dict[str, Scale]
34
+ weight: float
35
+ mu: dict[str, float]
36
+ sigma: dict[str, float]
37
+ loglik: float
38
+ loglik_null: float
39
+ delta_bic: float
40
+ detected: bool
41
+ posterior: FloatArray
42
+
43
+ @property
44
+ def converted(self) -> BoolArray:
45
+ """Rows whose posterior probability of being converted exceeds 0.5."""
46
+ with np.errstate(invalid="ignore"):
47
+ return np.asarray(self.posterior > 0.5, dtype=np.bool_)
48
+
49
+ def summary(self) -> str:
50
+ """Plain-text report of the fit."""
51
+ n_conv = int(self.converted.sum())
52
+ n_obs = int(np.isfinite(self.posterior).sum())
53
+ lines = [
54
+ f"unitsleuth row-consistent fit over {len(self.columns)} column(s)",
55
+ f" rows analysed: {n_obs}",
56
+ f" rows flagged as converted: {n_conv} "
57
+ f"({self.weight:.1%} estimated share)",
58
+ f" evidence (delta BIC vs no mixing): {self.delta_bic:.1f} "
59
+ f"-> {'mixed units detected' if self.detected else 'no mixing detected'}",
60
+ ]
61
+ for col in self.columns:
62
+ c = self.conversions[col]
63
+ lines.append(
64
+ f" {col}: {c.name} on {self.scales[col]} scale, "
65
+ f"mu={self.mu[col]:.4g}, sigma={self.sigma[col]:.4g}"
66
+ )
67
+ return "\n".join(lines)
68
+
69
+
70
+ def _to_arrays(data: Mapping[str, ArrayLike]) -> dict[str, FloatArray]:
71
+ out: dict[str, FloatArray] = {
72
+ k: np.asarray(v, dtype=np.float64).ravel() for k, v in data.items()
73
+ }
74
+ lengths = {a.size for a in out.values()}
75
+ if len(lengths) > 1:
76
+ raise ValueError("all columns must have the same length")
77
+ return out
78
+
79
+
80
+ def detect_rows(
81
+ data: Any,
82
+ conversions: Mapping[str, Conversion],
83
+ *,
84
+ min_delta_bic: float = 10.0,
85
+ min_weight: float = 0.005,
86
+ min_n: int = 10,
87
+ max_iter: int = 500,
88
+ tol: float = 1e-10,
89
+ ) -> RowFit:
90
+ """Fit a shared row flag over the columns named in ``conversions``.
91
+
92
+ A flagged row is one where every observed column is ``conversion.forward`` of
93
+ its base-unit value. The share of flagged rows is free, so the flagged unit may
94
+ be the majority.
95
+ """
96
+ cols = tuple(conversions)
97
+ if not cols:
98
+ raise ValueError("conversions is empty")
99
+ missing = [c for c in cols if isinstance(data, Mapping) and c not in data]
100
+ if missing:
101
+ raise KeyError(f"columns not in data: {missing}")
102
+ arrays = _to_arrays(columns_of(data, cols))
103
+ raw = np.column_stack([arrays[c] for c in cols])
104
+ mask = np.isfinite(raw)
105
+ row_obs = mask.any(axis=1)
106
+ if int(row_obs.sum()) < min_n:
107
+ raise ValueError(f"need at least {min_n} rows with a finite value")
108
+
109
+ t_cols: list[FloatArray] = []
110
+ y_cols: list[FloatArray] = []
111
+ jac = np.zeros(len(cols))
112
+ scales: dict[str, Scale] = {}
113
+ for j, c in enumerate(cols):
114
+ conv = conversions[c]
115
+ x = raw[:, j]
116
+ obs = mask[:, j]
117
+ positive = bool(np.all(x[obs] > 0)) if obs.any() else False
118
+ if conv.is_multiplicative and conv.scale > 0 and positive:
119
+ scales[c] = "log"
120
+ t = np.where(obs, np.log(np.where(obs, x, 1.0)), 0.0)
121
+ y = t - np.log(conv.scale)
122
+ else:
123
+ scales[c] = "linear"
124
+ t = np.where(obs, x, 0.0)
125
+ y = (t - conv.offset) / conv.scale
126
+ jac[j] = -np.log(abs(conv.scale))
127
+ if obs.sum() < 2 or float(np.std(t[obs])) == 0.0:
128
+ raise ValueError(f"column {c!r} is constant or has too few values")
129
+ t_cols.append(t)
130
+ y_cols.append(y)
131
+ t_mat = np.column_stack(t_cols)
132
+ y_mat = np.column_stack(y_cols)
133
+ m = mask.astype(np.float64)
134
+
135
+ n_obs_col = m.sum(axis=0)
136
+ sd = np.array([np.std(t_mat[mask[:, j], j]) for j in range(len(cols))])
137
+ floor = 1e-3 * sd
138
+ med = np.array([np.median(t_mat[mask[:, j], j]) for j in range(len(cols))])
139
+ mad = np.array(
140
+ [
141
+ 1.4826 * np.median(np.abs(t_mat[mask[:, j], j] - med[j]))
142
+ for j in range(len(cols))
143
+ ]
144
+ )
145
+ sig_init = np.maximum(mad / 2.0, floor)
146
+
147
+ def logpost(
148
+ w: float, mu: FloatArray, sig: FloatArray
149
+ ) -> tuple[FloatArray, FloatArray]:
150
+ a = np.log1p(-w) + (_normal_logpdf(t_mat, mu, sig) * m).sum(axis=1)
151
+ b = np.log(w) + ((_normal_logpdf(y_mat, mu, sig) + jac) * m).sum(axis=1)
152
+ return a, b
153
+
154
+ best: tuple[float, FloatArray, FloatArray, float] | None = None
155
+ for q in (0.1, 0.25, 0.5, 0.75, 0.9):
156
+ mu_start = np.array(
157
+ [np.quantile(t_mat[mask[:, j], j], q) for j in range(len(cols))]
158
+ )
159
+ for w0 in (0.05, 0.2, 0.5, 0.8):
160
+ mu, sig, w = mu_start.copy(), sig_init.copy(), w0
161
+ prev, ll = -np.inf, -np.inf
162
+ for _ in range(max_iter):
163
+ a, b = logpost(w, mu, sig)
164
+ mx = np.maximum(a, b)
165
+ lse = mx + np.log(np.exp(a - mx) + np.exp(b - mx))
166
+ ll = float(lse[row_obs].sum())
167
+ r = np.exp(b - lse)
168
+ w = float(np.clip(r[row_obs].mean(), 1e-6, 1 - 1e-6))
169
+ rc = r[:, None]
170
+ mu = np.sum(m * ((1 - rc) * t_mat + rc * y_mat), axis=0) / n_obs_col
171
+ var = (
172
+ np.sum(
173
+ m * ((1 - rc) * (t_mat - mu) ** 2 + rc * (y_mat - mu) ** 2),
174
+ axis=0,
175
+ )
176
+ / n_obs_col
177
+ )
178
+ sig = np.maximum(np.sqrt(var), floor)
179
+ if abs(ll - prev) < tol * (1.0 + abs(ll)):
180
+ break
181
+ prev = ll
182
+ if best is None or ll > best[3]:
183
+ best = (w, mu, sig, ll)
184
+ assert best is not None
185
+ w, mu, sig, ll = best
186
+
187
+ ll0 = 0.0
188
+ for j in range(len(cols)):
189
+ tj = t_mat[mask[:, j], j]
190
+ ll0 += float(_normal_logpdf(tj, float(tj.mean()), float(tj.std())).sum())
191
+ n = int(row_obs.sum())
192
+ j_cols = len(cols)
193
+ delta_bic = (-2.0 * ll0 + 2 * j_cols * np.log(n)) - (
194
+ -2.0 * ll + (2 * j_cols + 1) * np.log(n)
195
+ )
196
+
197
+ a, b = logpost(w, mu, sig)
198
+ post = 1.0 / (1.0 + np.exp(np.clip(a - b, -700, 700)))
199
+ post[~row_obs] = np.nan
200
+ return RowFit(
201
+ columns=cols,
202
+ conversions=dict(conversions),
203
+ scales=scales,
204
+ weight=w,
205
+ mu={c: float(mu[j]) for j, c in enumerate(cols)},
206
+ sigma={c: float(sig[j]) for j, c in enumerate(cols)},
207
+ loglik=ll,
208
+ loglik_null=ll0,
209
+ delta_bic=float(delta_bic),
210
+ detected=bool(delta_bic >= min_delta_bic and w >= min_weight),
211
+ posterior=post,
212
+ )
213
+
214
+
215
+ def repair(
216
+ data: Any,
217
+ fit: RowFit,
218
+ *,
219
+ threshold: float = 0.5,
220
+ target: Target = "majority",
221
+ ) -> Any:
222
+ """Return copies of the fitted columns harmonised to a single unit.
223
+
224
+ ``target="base"`` converts flagged rows back to the base unit,
225
+ ``"converted"`` converts unflagged rows forward, and ``"majority"`` (default)
226
+ picks whichever unit most rows are already in. Rows with an undefined
227
+ posterior are left untouched. For a dataframe input the result is a
228
+ dataframe of the same type with the fitted columns replaced; for a mapping
229
+ it is a dict of arrays.
230
+ """
231
+ arrays = _to_arrays(columns_of(data, fit.columns))
232
+ post = fit.posterior
233
+ with np.errstate(invalid="ignore"):
234
+ flagged = post > threshold
235
+ unflagged = post <= threshold
236
+ if target == "majority":
237
+ target = "base" if int(unflagged.sum()) >= int(flagged.sum()) else "converted"
238
+ out: dict[str, FloatArray] = {}
239
+ for c in fit.columns:
240
+ conv = fit.conversions[c]
241
+ x = arrays[c].copy()
242
+ if target == "base":
243
+ x[flagged] = conv.inverse(x[flagged])
244
+ else:
245
+ x[unflagged] = conv.forward(x[unflagged])
246
+ out[c] = x
247
+ return replace_columns(data, out) if is_frame(data) else out
unitsleuth/py.typed ADDED
File without changes
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.5
2
+ Name: unitsleuth
3
+ Version: 0.1.0
4
+ Summary: Detect mixed measurement units in unlabeled numeric columns from the numbers alone.
5
+ Project-URL: Homepage, https://github.com/omarfaruqe/unitsleuth
6
+ Project-URL: Issues, https://github.com/omarfaruqe/unitsleuth/issues
7
+ Author-email: Md Omar Faruqe <faruqe@ru.ac.bd>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: data-cleaning,data-quality,measurement,mixture-model,units
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: narwhals>=1.20
18
+ Requires-Dist: numpy>=1.24
19
+ Description-Content-Type: text/markdown
20
+
21
+ # unitsleuth
22
+
23
+ Detect mixed measurement units in an unlabeled numeric column, from the numbers alone.
24
+
25
+ A height column where most rows are centimetres and a few are inches, or a
26
+ temperature column that mixes °C and °F, looks fine to a schema check. unitsleuth
27
+ fits a mixture model per known unit conversion and tells you which rows were
28
+ probably recorded in the other unit.
29
+
30
+ This is a Python implementation of the mixture-model approach of Di Zio, Guarnera
31
+ and Luzi (2005, *Survey Methodology*), extended with affine conversions
32
+ (e.g. °C to °F) and a catalog of common unit pairs. It is not a new method.
33
+
34
+ ## Quick start
35
+
36
+ ```python
37
+ import numpy as np
38
+ import unitsleuth as us
39
+
40
+ heights = np.concatenate([np.random.normal(170, 9, 900), np.random.normal(67, 3.5, 100)])
41
+ best = us.detect(heights, quantity="length")[0]
42
+ print(best.label, best.weight, best.detected)
43
+ rows = best.converted # boolean mask of rows likely in the other unit
44
+ ```
45
+
46
+ ### Whole rows in the wrong unit
47
+
48
+ When a record was entered in one unit system, every column in that row is
49
+ affected. `detect_rows` shares one flag per row across columns, and `repair`
50
+ harmonises the data. `df` can be a pandas or Polars DataFrame (via
51
+ [Narwhals](https://narwhals-dev.github.io/narwhals/)) or a dict of arrays:
52
+
53
+ ```python
54
+ cat = us.catalog
55
+ fit = us.detect_rows(df, {"height": cat.get("cm", "in"), "weight": cat.get("kg", "lb")})
56
+ print(fit.summary())
57
+ fixed = us.repair(df, fit) # same dataframe type, columns corrected
58
+ ```
59
+
60
+ ### Command line
61
+
62
+ ```
63
+ unitsleuth data.csv --col height:cm,in --col weight:kg,lb --out data_fixed.csv
64
+ ```
65
+
66
+ Prints the report and, with `--out`, writes a harmonised CSV. The exit code is 1
67
+ when mixed units are detected, 0 when not and 2 on usage errors, so it can gate a
68
+ data pipeline.
69
+
70
+ ## Status
71
+
72
+ Alpha. Univariate and row-consistent multivariate detection and repair work;
73
+ dataframe integration (pandas, Polars) and a CLI are planned. Detection needs the units to be
74
+ separated relative to the spread of the data, so it cannot distinguish them when
75
+ natural variation is wide compared with the conversion factor.
76
+
77
+ ## Development
78
+
79
+ ```
80
+ uv sync
81
+ uv run pytest
82
+ ```
83
+
84
+ ## Validation
85
+
86
+ - **Negative control (real data):** on the Palmer penguins dataset (342 birds, 4 numeric columns, 24 catalog conversions each), nothing was flagged: 0 of 96 tests, despite three species with different body sizes.
87
+ - **Positive control (semi-real):** converting a known 5%, 15% or 30% of the penguins' body mass (kg to lb) and bill length (cm to in) and running `detect_rows` over 3 random seeds each, the injected rows were found with 100% row accuracy in all 9 runs, and the estimated share was close to the injected share.
88
+ - **Limits:** these results are for well-separated unit pairs. When the natural spread of a column is wide relative to the conversion factor, the units cannot be told apart from the numbers alone, and the evidence score will say so.
@@ -0,0 +1,13 @@
1
+ unitsleuth/__init__.py,sha256=R1_4M93tZAMMpx9Fb8tGV9ybpA3_eQmOIO4aqsTFxZk,450
2
+ unitsleuth/__main__.py,sha256=Ug-gHuKo4HYuqaiIMLyzTPbSgaLmc3uwDd6b-uBwkSI,58
3
+ unitsleuth/_frame.py,sha256=c3pwI0preMfDNnfNtCvSU_VhcXsiePigj11IMoji9JE,1665
4
+ unitsleuth/catalog.py,sha256=gYmU-8biQ--mxzfyHV7dIyfphuswMmgPHf4xwLDFBRA,3961
5
+ unitsleuth/cli.py,sha256=HFqTViBDZCdgMlZNAXl0Hho2Wnm56G0F08ATnB9x0ns,3301
6
+ unitsleuth/detect.py,sha256=uE1IH1CQgY2mYxaWjgzyP9HW1BAIbT1XJVaLIBAqn-U,7714
7
+ unitsleuth/multivariate.py,sha256=WnviqhaRg7oXS4npAFbdPkR60q4fJFXC7iN3ZySnzYY,8899
8
+ unitsleuth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ unitsleuth-0.1.0.dist-info/METADATA,sha256=Ecm7Pqkl-fLcoydpuLurA9Jh2AOeiQ9WLRIMcMThCVE,3711
10
+ unitsleuth-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ unitsleuth-0.1.0.dist-info/entry_points.txt,sha256=bWfhSgA0W__JOH0lpBcMK1-B0knTg9By_FkGpK9XvN4,51
12
+ unitsleuth-0.1.0.dist-info/licenses/LICENSE,sha256=4qh7wJiUhE3j5jFfz3qME9Fnlv-OOuTS5sHTLw_mpUw,1071
13
+ unitsleuth-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ unitsleuth = unitsleuth.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Md Omar Faruqe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.