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 +19 -0
- unitsleuth/__main__.py +3 -0
- unitsleuth/_frame.py +50 -0
- unitsleuth/catalog.py +117 -0
- unitsleuth/cli.py +104 -0
- unitsleuth/detect.py +236 -0
- unitsleuth/multivariate.py +247 -0
- unitsleuth/py.typed +0 -0
- unitsleuth-0.1.0.dist-info/METADATA +88 -0
- unitsleuth-0.1.0.dist-info/RECORD +13 -0
- unitsleuth-0.1.0.dist-info/WHEEL +4 -0
- unitsleuth-0.1.0.dist-info/entry_points.txt +2 -0
- unitsleuth-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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,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.
|