unitsleuth 0.1.0__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.
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Catalog of affine unit conversions (length, mass, temperature, volume, pressure, speed, time, ratios, power-of-ten errors).
6
+ - Univariate mixture detector with per-row posteriors and BIC-based evidence.
7
+ - Row-consistent multivariate detection and repair.
8
+ - pandas and Polars support through Narwhals.
9
+ - Command-line interface.
@@ -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.
@@ -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,68 @@
1
+ # unitsleuth
2
+
3
+ Detect mixed measurement units in an unlabeled numeric column, from the numbers alone.
4
+
5
+ A height column where most rows are centimetres and a few are inches, or a
6
+ temperature column that mixes °C and °F, looks fine to a schema check. unitsleuth
7
+ fits a mixture model per known unit conversion and tells you which rows were
8
+ probably recorded in the other unit.
9
+
10
+ This is a Python implementation of the mixture-model approach of Di Zio, Guarnera
11
+ and Luzi (2005, *Survey Methodology*), extended with affine conversions
12
+ (e.g. °C to °F) and a catalog of common unit pairs. It is not a new method.
13
+
14
+ ## Quick start
15
+
16
+ ```python
17
+ import numpy as np
18
+ import unitsleuth as us
19
+
20
+ heights = np.concatenate([np.random.normal(170, 9, 900), np.random.normal(67, 3.5, 100)])
21
+ best = us.detect(heights, quantity="length")[0]
22
+ print(best.label, best.weight, best.detected)
23
+ rows = best.converted # boolean mask of rows likely in the other unit
24
+ ```
25
+
26
+ ### Whole rows in the wrong unit
27
+
28
+ When a record was entered in one unit system, every column in that row is
29
+ affected. `detect_rows` shares one flag per row across columns, and `repair`
30
+ harmonises the data. `df` can be a pandas or Polars DataFrame (via
31
+ [Narwhals](https://narwhals-dev.github.io/narwhals/)) or a dict of arrays:
32
+
33
+ ```python
34
+ cat = us.catalog
35
+ fit = us.detect_rows(df, {"height": cat.get("cm", "in"), "weight": cat.get("kg", "lb")})
36
+ print(fit.summary())
37
+ fixed = us.repair(df, fit) # same dataframe type, columns corrected
38
+ ```
39
+
40
+ ### Command line
41
+
42
+ ```
43
+ unitsleuth data.csv --col height:cm,in --col weight:kg,lb --out data_fixed.csv
44
+ ```
45
+
46
+ Prints the report and, with `--out`, writes a harmonised CSV. The exit code is 1
47
+ when mixed units are detected, 0 when not and 2 on usage errors, so it can gate a
48
+ data pipeline.
49
+
50
+ ## Status
51
+
52
+ Alpha. Univariate and row-consistent multivariate detection and repair work;
53
+ dataframe integration (pandas, Polars) and a CLI are planned. Detection needs the units to be
54
+ separated relative to the spread of the data, so it cannot distinguish them when
55
+ natural variation is wide compared with the conversion factor.
56
+
57
+ ## Development
58
+
59
+ ```
60
+ uv sync
61
+ uv run pytest
62
+ ```
63
+
64
+ ## Validation
65
+
66
+ - **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.
67
+ - **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.
68
+ - **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,61 @@
1
+ [project]
2
+ name = "unitsleuth"
3
+ version = "0.1.0"
4
+ description = "Detect mixed measurement units in unlabeled numeric columns from the numbers alone."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [{ name = "Md Omar Faruqe", email = "faruqe@ru.ac.bd" }]
8
+ requires-python = ">=3.10"
9
+ keywords = ["data-quality", "units", "measurement", "mixture-model", "data-cleaning"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Science/Research",
13
+ "Programming Language :: Python :: 3",
14
+ "Topic :: Scientific/Engineering :: Information Analysis",
15
+ "Typing :: Typed",
16
+ ]
17
+ dependencies = [
18
+ "narwhals>=1.20",
19
+ "numpy>=1.24",
20
+ ]
21
+
22
+ [project.scripts]
23
+ unitsleuth = "unitsleuth.cli:main"
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/omarfaruqe/unitsleuth"
27
+ Issues = "https://github.com/omarfaruqe/unitsleuth/issues"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "pytest>=8",
36
+ "pytest-cov>=5",
37
+ "hypothesis>=6",
38
+ "ruff>=0.6",
39
+ "mypy>=1.11",
40
+ "pandas>=2.3.3",
41
+ "polars>=2.0.0",
42
+ "pyarrow>=25.0.1",
43
+ ]
44
+
45
+ [tool.ruff]
46
+ line-length = 88
47
+ target-version = "py310"
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "I", "UP", "B", "SIM", "NPY"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ addopts = "-ra --strict-markers"
55
+
56
+ [tool.mypy]
57
+ strict = true
58
+ python_version = "3.10"
59
+
60
+ [tool.hatch.build.targets.sdist]
61
+ include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
@@ -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
+ ]
@@ -0,0 +1,3 @@
1
+ from unitsleuth.cli import main
2
+
3
+ raise SystemExit(main())
@@ -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()
@@ -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}")
@@ -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())