Source code for modelly.provenance.compare

"""Find the first place where two runs' output tables differ.

Numeric columns are compared with ``numpy.isclose`` under the given tolerances (both 0
by default: the values must be identical); other columns must be equal. Missing values
match missing values.
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd


[docs] @dataclass(frozen=True) class Difference: """Where an output differs: table, then column and row when the shapes agree.""" table: str message: str column: str | None = None row: int | None = None def __str__(self) -> str: """``table 'model', column 'Gini', row 12: expected 0.4, got 0.5``.""" where = [f"table {self.table!r}"] if self.column is not None: where.append(f"column {self.column!r}") if self.row is not None: where.append(f"row {self.row}") return f"{', '.join(where)}: {self.message}"
[docs] @dataclass(frozen=True) class Tolerance: """Relative and absolute tolerance for numeric columns.""" rtol: float = 0.0 atol: float = 0.0
def _numeric(series: pd.Series) -> bool: """True for int and float columns (bool and object columns compare exactly).""" return pd.api.types.is_numeric_dtype(series) and not pd.api.types.is_bool_dtype( series ) def _equal(expected: pd.Series, actual: pd.Series, tolerance: Tolerance) -> np.ndarray: """A boolean array: where the two columns agree.""" if _numeric(expected) and _numeric(actual): return np.isclose( actual.to_numpy(dtype=float), expected.to_numpy(dtype=float), rtol=tolerance.rtol, atol=tolerance.atol, equal_nan=True, ) both_missing = expected.isna().to_numpy() & actual.isna().to_numpy() same = expected.astype(object).to_numpy() == actual.astype(object).to_numpy() return np.asarray(same, dtype=bool) | both_missing def _shape_difference( table: str, expected: pd.DataFrame, actual: pd.DataFrame ) -> Difference | None: """A difference in the columns or the number of rows, if any.""" want, got = [str(c) for c in expected.columns], [str(c) for c in actual.columns] if want != got: missing = sorted(set(want) - set(got)) extra = sorted(set(got) - set(want)) return Difference( table, f"columns differ: expected {want}, got {got} " f"(missing {missing}, extra {extra})", ) if len(expected) != len(actual): return Difference( table, f"row counts differ: expected {len(expected)}, got {len(actual)}" ) return None
[docs] def first_difference( table: str, expected: pd.DataFrame, actual: pd.DataFrame, tolerance: Tolerance = Tolerance(), # noqa: B008 (frozen, immutable) ) -> Difference | None: """The first differing column, row and values of one table, or None if they match. Columns are scanned in table order and rows top to bottom; the reported row is the row's position (0-based) in the table. """ shape = _shape_difference(table, expected, actual) if shape is not None: return shape for column in expected.columns: agree = _equal(expected[column], actual[column], tolerance) if agree.all(): continue row = int(np.argmin(agree)) message = ( f"expected {expected[column].iloc[row]!r}, got {actual[column].iloc[row]!r}" ) return Difference(table, message, column=str(column), row=row) return None