ledgerlens 0.1.2__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.
ledgerlens/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """ledgerlens - forensic accounting analytics for Python.
2
+
3
+ Three building blocks:
4
+
5
+ * :mod:`ledgerlens.benford` - Benford's Law digit tests following Nigrini (2012)
6
+ * :mod:`ledgerlens.jet` - journal entry tests for general-ledger data
7
+ * :mod:`ledgerlens.scores` - Beneish M-Score, Altman Z-Score, Piotroski F-Score
8
+
9
+ Quick start::
10
+
11
+ import ledgerlens as ll
12
+
13
+ res = ll.benford(df["Amount"], test="first_two")
14
+ print(res.summary())
15
+
16
+ report = ll.JournalEntryTester(gl, entry_id="je_id", date="post_date",
17
+ amount="amount", account="account").run_all()
18
+ report.to_excel("jet_report.xlsx")
19
+ """
20
+
21
+ from .benford import (
22
+ BenfordResult,
23
+ benford,
24
+ expected_distribution,
25
+ mantissa_arc_test,
26
+ number_duplication,
27
+ second_order,
28
+ summation_test,
29
+ )
30
+ from .jet import JETReport, JournalEntryTester, TestResult
31
+ from .scores import (
32
+ AltmanResult,
33
+ BeneishResult,
34
+ PiotroskiResult,
35
+ altman_z,
36
+ beneish_m,
37
+ piotroski_f,
38
+ )
39
+
40
+ __version__ = "0.1.2"
41
+
42
+ __all__ = [
43
+ "AltmanResult",
44
+ "BeneishResult",
45
+ "BenfordResult",
46
+ "JETReport",
47
+ "JournalEntryTester",
48
+ "PiotroskiResult",
49
+ "TestResult",
50
+ "altman_z",
51
+ "benford",
52
+ "beneish_m",
53
+ "expected_distribution",
54
+ "mantissa_arc_test",
55
+ "number_duplication",
56
+ "piotroski_f",
57
+ "second_order",
58
+ "summation_test",
59
+ "__version__",
60
+ ]
ledgerlens/_utils.py ADDED
@@ -0,0 +1,90 @@
1
+ """Internal helpers shared across modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, Tuple
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ # Relative nudge used when extracting digits. Binary floating point cannot
11
+ # represent most decimals exactly (0.29 / 0.1 == 2.8999999999999995), which
12
+ # would make naive truncation report 28 instead of 29 as the first-two digits.
13
+ # Accounting amounts rarely carry more than ~12 significant digits, so a
14
+ # 1e-12 relative nudge fixes representation error without changing any value
15
+ # a human would have typed.
16
+ _EPS = 1e-12
17
+
18
+
19
+ def clean_numeric(
20
+ data, sign: str = "positive"
21
+ ) -> Tuple[np.ndarray, pd.Index]:
22
+ """Return (positive magnitudes, original index) after the sign filter.
23
+
24
+ ``sign`` follows the usual Benford-software convention:
25
+
26
+ * ``"positive"`` - keep values > 0 (default; recommended by Nigrini)
27
+ * ``"negative"`` - keep values < 0, analysed as magnitudes
28
+ * ``"both"`` - keep every non-zero value, analysed as magnitudes
29
+ """
30
+ if isinstance(data, pd.DataFrame):
31
+ raise TypeError("Pass a single column (Series), not a DataFrame.")
32
+ s = data if isinstance(data, pd.Series) else pd.Series(np.asarray(data).ravel())
33
+ s = pd.to_numeric(s, errors="coerce").astype(float)
34
+ s = s[np.isfinite(s.to_numpy())]
35
+
36
+ if sign == "positive":
37
+ s = s[s > 0]
38
+ elif sign == "negative":
39
+ s = -s[s < 0]
40
+ elif sign == "both":
41
+ s = s[s != 0].abs()
42
+ else:
43
+ raise ValueError("sign must be 'positive', 'negative' or 'both'")
44
+ return s.to_numpy(dtype=float), s.index
45
+
46
+
47
+ def leading_digits(x: np.ndarray, n: int) -> np.ndarray:
48
+ """First ``n`` significant digits of each positive value, as integers.
49
+
50
+ ``leading_digits([1234.5, 0.029], 2) -> [12, 29]``
51
+ """
52
+ x = np.asarray(x, dtype=float)
53
+ if x.size == 0:
54
+ return np.array([], dtype=np.int64)
55
+ if np.any(x <= 0):
56
+ raise ValueError("leading_digits expects strictly positive values")
57
+ e = np.floor(np.log10(x))
58
+ s = x / np.power(10.0, e)
59
+ # log10 can land a hair on the wrong side of an integer for exact powers.
60
+ hi = s >= 10
61
+ s[hi] /= 10
62
+ lo = s < 1
63
+ s[lo] *= 10
64
+ d = np.floor(s * 10 ** (n - 1) * (1 + _EPS)).astype(np.int64)
65
+ # 9.999999999999998 nudged to 10.000000000001 really means 10**(e+1).
66
+ d[d >= 10**n] = 10 ** (n - 1)
67
+ return d
68
+
69
+
70
+ def last_two_digits(x: np.ndarray, decimals: int = 0) -> Tuple[np.ndarray, np.ndarray]:
71
+ """Last two digits of each value after scaling by ``10**decimals``.
72
+
73
+ Returns ``(digits, mask)`` where ``mask`` marks values whose scaled
74
+ integer is at least 10 (a one-digit number has no "last two digits").
75
+ """
76
+ scaled = np.rint(np.asarray(x, dtype=float) * 10**decimals)
77
+ mask = scaled >= 10
78
+ digits = np.mod(scaled[mask], 100).astype(np.int64)
79
+ return digits, mask
80
+
81
+
82
+ def require_columns(df: pd.DataFrame, cols: dict) -> None:
83
+ missing = {k: v for k, v in cols.items() if v is not None and v not in df.columns}
84
+ if missing:
85
+ detail = ", ".join(f"{k}={v!r}" for k, v in missing.items())
86
+ raise KeyError(f"Column(s) not found in data: {detail}")
87
+
88
+
89
+ def fmt_pct(x: Optional[float], digits: int = 1) -> str:
90
+ return "n/a" if x is None or not np.isfinite(x) else f"{x * 100:.{digits}f}%"
ledgerlens/benford.py ADDED
@@ -0,0 +1,518 @@
1
+ """Benford's Law tests for forensic analytics.
2
+
3
+ Implements the digit tests described in Mark Nigrini, *Benford's Law:
4
+ Applications for Forensic Accounting, Auditing, and Fraud Detection*
5
+ (Wiley, 2012) - the de facto standard in audit practice:
6
+
7
+ * first-digit, second-digit, first-two-digits, first-three-digits tests
8
+ * last-two-digits test (invented / rounded numbers)
9
+ * Mean Absolute Deviation (MAD) with Nigrini's conformity ranges
10
+ * per-digit z-statistics with continuity correction
11
+ * chi-square and Kolmogorov-Smirnov tests
12
+ * summation test, number duplication test, second-order test
13
+ * distortion factor and mantissa arc test
14
+
15
+ Benford's Law does **not** prove fraud. Nonconformity tells an auditor where
16
+ to look; whether the data *should* follow Benford at all (no built-in minimum
17
+ or maximum, no assigned numbers such as invoice IDs, enough records spanning
18
+ several orders of magnitude) has to be judged first.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import warnings
24
+ from dataclasses import dataclass, field
25
+ from typing import Dict, Optional, Sequence, Tuple, Union
26
+
27
+ import numpy as np
28
+ import pandas as pd
29
+ from scipy import stats
30
+
31
+ from ._utils import clean_numeric, last_two_digits, leading_digits
32
+
33
+ __all__ = [
34
+ "BenfordResult",
35
+ "benford",
36
+ "expected_distribution",
37
+ "mantissa_arc_test",
38
+ "number_duplication",
39
+ "second_order",
40
+ "summation_test",
41
+ "MAD_THRESHOLDS",
42
+ ]
43
+
44
+ TestName = str
45
+
46
+ _TEST_ALIASES = {
47
+ "first": "first",
48
+ "first_digit": "first",
49
+ "f1d": "first",
50
+ 1: "first",
51
+ "second": "second",
52
+ "second_digit": "second",
53
+ "f2nd": "second",
54
+ "first_two": "first_two",
55
+ "first_two_digits": "first_two",
56
+ "f2d": "first_two",
57
+ 2: "first_two",
58
+ "first_three": "first_three",
59
+ "first_three_digits": "first_three",
60
+ "f3d": "first_three",
61
+ 3: "first_three",
62
+ "last_two": "last_two",
63
+ "last_two_digits": "last_two",
64
+ "l2d": "last_two",
65
+ }
66
+
67
+ _TEST_LABEL = {
68
+ "first": "First Digit",
69
+ "second": "Second Digit",
70
+ "first_two": "First-Two Digits",
71
+ "first_three": "First-Three Digits",
72
+ "last_two": "Last-Two Digits",
73
+ }
74
+
75
+ #: Nigrini (2012), Table 7.1: upper bounds of the close / acceptable /
76
+ #: marginally-acceptable conformity ranges. Above the last bound = nonconformity.
77
+ MAD_THRESHOLDS: Dict[str, Tuple[float, float, float]] = {
78
+ "first": (0.006, 0.012, 0.015),
79
+ "second": (0.008, 0.010, 0.012),
80
+ "first_two": (0.0012, 0.0018, 0.0022),
81
+ "first_three": (0.00036, 0.00044, 0.00050),
82
+ }
83
+
84
+ _CONFORMITY_LABELS = (
85
+ "Close conformity",
86
+ "Acceptable conformity",
87
+ "Marginally acceptable conformity",
88
+ "Nonconformity",
89
+ )
90
+
91
+ # Nigrini drops numbers that are too small to have the digits under test.
92
+ _DEFAULT_MIN_VALUE = {
93
+ "first": None,
94
+ "second": 10.0,
95
+ "first_two": 10.0,
96
+ "first_three": 100.0,
97
+ "last_two": None,
98
+ }
99
+
100
+ # Expected mean of values collapsed into [10, 100) under Benford: 90 / ln(10).
101
+ _BENFORD_COLLAPSED_MEAN = 90 / np.log(10)
102
+
103
+
104
+ def _resolve_test(test) -> str:
105
+ key = test.lower() if isinstance(test, str) else test
106
+ if key not in _TEST_ALIASES:
107
+ raise ValueError(
108
+ f"Unknown test {test!r}. Use one of: "
109
+ "'first', 'second', 'first_two', 'first_three', 'last_two'."
110
+ )
111
+ return _TEST_ALIASES[key]
112
+
113
+
114
+ def expected_distribution(test: Union[str, int] = "first_two") -> pd.Series:
115
+ """Benford (or uniform, for ``last_two``) expected proportions, indexed by digit."""
116
+ test = _resolve_test(test)
117
+ if test == "first":
118
+ d = np.arange(1, 10)
119
+ p = np.log10(1 + 1 / d)
120
+ elif test == "second":
121
+ d = np.arange(0, 10)
122
+ k = np.arange(1, 10)[:, None]
123
+ p = np.log10(1 + 1 / (10 * k + d)).sum(axis=0)
124
+ elif test == "first_two":
125
+ d = np.arange(10, 100)
126
+ p = np.log10(1 + 1 / d)
127
+ elif test == "first_three":
128
+ d = np.arange(100, 1000)
129
+ p = np.log10(1 + 1 / d)
130
+ else: # last_two
131
+ d = np.arange(0, 100)
132
+ p = np.full(100, 0.01)
133
+ return pd.Series(p, index=pd.Index(d, name="digits"), name="expected")
134
+
135
+
136
+ def _conformity(test: str, mad: float) -> Optional[str]:
137
+ if test not in MAD_THRESHOLDS or not np.isfinite(mad):
138
+ return None
139
+ close, acceptable, marginal = MAD_THRESHOLDS[test]
140
+ if mad <= close:
141
+ return _CONFORMITY_LABELS[0]
142
+ if mad <= acceptable:
143
+ return _CONFORMITY_LABELS[1]
144
+ if mad <= marginal:
145
+ return _CONFORMITY_LABELS[2]
146
+ return _CONFORMITY_LABELS[3]
147
+
148
+
149
+ def _z_stats(actual: np.ndarray, expected: np.ndarray, n: int) -> np.ndarray:
150
+ """Nigrini's z-statistic; the 1/(2N) continuity correction is applied only
151
+ when it is smaller than the absolute difference."""
152
+ diff = np.abs(actual - expected)
153
+ cc = 1 / (2 * n)
154
+ num = np.where(cc < diff, diff - cc, diff)
155
+ return num / np.sqrt(expected * (1 - expected) / n)
156
+
157
+
158
+ def distortion_factor(values: np.ndarray) -> float:
159
+ """Nigrini's distortion factor in percent.
160
+
161
+ Values >= 10 are collapsed into [10, 100) and their mean compared with
162
+ the Benford expectation 90 / ln(10) = 39.0865. Negative DF means the data
163
+ lean toward smaller numbers (e.g. amounts shaded under approval limits);
164
+ positive DF means inflated numbers.
165
+ """
166
+ v = np.asarray(values, dtype=float)
167
+ v = v[v >= 10]
168
+ if v.size == 0:
169
+ return float("nan")
170
+ collapsed = v / np.power(10.0, np.floor(np.log10(v)) - 1)
171
+ collapsed = np.where(collapsed >= 100, collapsed / 10, collapsed)
172
+ return float((collapsed.mean() - _BENFORD_COLLAPSED_MEAN) / _BENFORD_COLLAPSED_MEAN * 100)
173
+
174
+
175
+ def mantissa_arc_test(data, sign: str = "positive") -> Tuple[float, float]:
176
+ """Mantissa arc test (Alexander, 2009).
177
+
178
+ Maps each mantissa ``log10(x) mod 1`` onto the unit circle; under Benford
179
+ the mantissas are uniform and their centre of mass sits near the origin.
180
+ Returns ``(L2, p_value)`` with ``p = exp(-L2 * N)``. Large samples reject
181
+ easily - treat it as a supporting statistic, as Nigrini advises for every
182
+ significance test on big data.
183
+ """
184
+ x, _ = clean_numeric(data, sign)
185
+ if x.size == 0:
186
+ return float("nan"), float("nan")
187
+ m = np.mod(np.log10(x), 1)
188
+ l2 = float(np.mean(np.cos(2 * np.pi * m)) ** 2 + np.mean(np.sin(2 * np.pi * m)) ** 2)
189
+ return l2, float(np.exp(-l2 * x.size))
190
+
191
+
192
+ @dataclass
193
+ class BenfordResult:
194
+ """Outcome of a single Benford digit test.
195
+
196
+ Attributes
197
+ ----------
198
+ test : str
199
+ ``first``, ``second``, ``first_two``, ``first_three`` or ``last_two``.
200
+ n : int
201
+ Number of records analysed after sign / minimum-value filtering.
202
+ table : pandas.DataFrame
203
+ One row per digit (combination): count, actual and expected
204
+ proportions, difference, z-statistic and a ``significant`` flag.
205
+ mad : float
206
+ Mean absolute deviation between actual and expected proportions.
207
+ conformity : str or None
208
+ Nigrini's conformity verdict for ``mad`` (None for ``last_two``).
209
+ chi2, chi2_p : float
210
+ Pearson chi-square statistic and p-value (df = digits - 1).
211
+ ks, ks_critical : float
212
+ Kolmogorov-Smirnov D statistic and its 5% critical value 1.36 / sqrt(N).
213
+ distortion_factor : float
214
+ Nigrini's distortion factor in percent (leading-digit tests only).
215
+ """
216
+
217
+ test: str
218
+ n: int
219
+ n_excluded: int
220
+ table: pd.DataFrame
221
+ mad: float
222
+ conformity: Optional[str]
223
+ chi2: float
224
+ chi2_df: int
225
+ chi2_p: float
226
+ ks: float
227
+ ks_critical: float
228
+ distortion_factor: float
229
+ alpha: float
230
+ values: pd.Series = field(repr=False)
231
+ digits: pd.Series = field(repr=False)
232
+
233
+ # ------------------------------------------------------------------ views
234
+ @property
235
+ def label(self) -> str:
236
+ return _TEST_LABEL[self.test]
237
+
238
+ @property
239
+ def ks_conforms(self) -> bool:
240
+ return bool(self.ks <= self.ks_critical)
241
+
242
+ @property
243
+ def mad_thresholds(self) -> Optional[Tuple[float, float, float]]:
244
+ return MAD_THRESHOLDS.get(self.test)
245
+
246
+ def suspects(self, top: Optional[int] = 10, only_excess: bool = True) -> pd.DataFrame:
247
+ """Digit combinations with statistically significant spikes, largest z first.
248
+
249
+ ``only_excess=True`` keeps digits that occur *more* often than expected,
250
+ which is where invented or threshold-driven amounts show up.
251
+ """
252
+ t = self.table[self.table["significant"]]
253
+ if only_excess:
254
+ t = t[t["difference"] > 0]
255
+ t = t.sort_values("z", ascending=False)
256
+ return t if top is None else t.head(top)
257
+
258
+ def records(self, digits: Union[int, Sequence[int], None] = None, top: int = 3) -> pd.Series:
259
+ """Original records whose digits are among ``digits``.
260
+
261
+ With ``digits=None`` the ``top`` most significant excess digits from
262
+ :meth:`suspects` are used. The returned Series keeps the original
263
+ index, so ``df.loc[result.records().index]`` pulls the full rows.
264
+ """
265
+ if digits is None:
266
+ digits = list(self.suspects(top=top).index)
267
+ elif np.isscalar(digits):
268
+ digits = [int(digits)]
269
+ mask = self.digits.isin(list(digits))
270
+ return self.values[mask]
271
+
272
+ def summary(self) -> str:
273
+ lines = [
274
+ f"Benford {self.label} test",
275
+ f" records analysed : {self.n:,}"
276
+ + (f" ({self.n_excluded:,} excluded by filters)" if self.n_excluded else ""),
277
+ f" MAD : {self.mad:.6f}"
278
+ + (f" -> {self.conformity}" if self.conformity else ""),
279
+ ]
280
+ if self.mad_thresholds:
281
+ c, a, m = self.mad_thresholds
282
+ lines.append(f" MAD ranges : close <= {c}, acceptable <= {a}, marginal <= {m}")
283
+ lines += [
284
+ f" chi-square : {self.chi2:,.2f} (df={self.chi2_df}, p={self.chi2_p:.4g})",
285
+ f" KS : D={self.ks:.5f} vs critical {self.ks_critical:.5f}"
286
+ f" -> {'conforms' if self.ks_conforms else 'does not conform'}",
287
+ ]
288
+ if np.isfinite(self.distortion_factor):
289
+ lines.append(f" distortion factor: {self.distortion_factor:+.2f}%")
290
+ if self.n < 1000:
291
+ lines.append(" caveat : fewer than 1,000 observations; digit tests are less reliable")
292
+ if len(self.values) and self.values.max() / self.values.min() < 100:
293
+ lines.append(" caveat : amounts span fewer than two orders of magnitude")
294
+ sus = self.suspects(top=5)
295
+ if len(sus):
296
+ width = 2 if self.test == "last_two" else 0
297
+ items = ", ".join(
298
+ f"{str(d).zfill(width)} (z={row.z:.1f}, {row.actual:.2%} vs {row.expected:.2%})"
299
+ for d, row in sus.iterrows()
300
+ )
301
+ lines.append(f" largest spikes : {items}")
302
+ else:
303
+ lines.append(" largest spikes : none significant at "
304
+ f"alpha={self.alpha}")
305
+ return "\n".join(lines)
306
+
307
+ def __str__(self) -> str: # pragma: no cover - cosmetic
308
+ return self.summary()
309
+
310
+ def to_dict(self) -> dict:
311
+ return {
312
+ "test": self.test,
313
+ "n": self.n,
314
+ "mad": self.mad,
315
+ "conformity": self.conformity,
316
+ "chi2": self.chi2,
317
+ "chi2_df": self.chi2_df,
318
+ "chi2_p": self.chi2_p,
319
+ "ks": self.ks,
320
+ "ks_critical": self.ks_critical,
321
+ "distortion_factor": self.distortion_factor,
322
+ }
323
+
324
+ def plot(self, ax=None, title: Optional[str] = None):
325
+ """Bar chart of actual vs expected proportions (requires matplotlib)."""
326
+ try:
327
+ import matplotlib.pyplot as plt
328
+ except ImportError as exc: # pragma: no cover
329
+ raise ImportError("plot() needs matplotlib: pip install 'ledgerlens[plot]'") from exc
330
+ if ax is None:
331
+ width = 10 if len(self.table) > 20 else 7
332
+ _, ax = plt.subplots(figsize=(width, 4))
333
+ t = self.table
334
+ x = np.arange(len(t))
335
+ colors = np.where(t["significant"] & (t["difference"] > 0), "#C0504D", "#4F81BD")
336
+ ax.bar(x, t["actual"], color=colors, width=0.8, label="Actual")
337
+ ax.plot(x, t["expected"], color="#222222", linewidth=1.6, label="Expected")
338
+ step = 1 if len(t) <= 20 else (10 if len(t) <= 100 else 100)
339
+ ticks = x[::step]
340
+ labels = [str(d).zfill(2) if self.test == "last_two" else str(d) for d in t.index[::step]]
341
+ ax.set_xticks(ticks)
342
+ ax.set_xticklabels(labels)
343
+ ax.set_ylabel("Proportion")
344
+ ax.set_title(title or f"{self.label} test (N={self.n:,}, MAD={self.mad:.4f})")
345
+ ax.legend(frameon=False)
346
+ ax.spines[["top", "right"]].set_visible(False)
347
+ return ax
348
+
349
+
350
+ def benford(
351
+ data,
352
+ test: Union[str, int] = "first_two",
353
+ sign: str = "positive",
354
+ min_value: Union[float, None, str] = "auto",
355
+ decimals: int = 0,
356
+ alpha: float = 0.05,
357
+ ) -> BenfordResult:
358
+ """Run a Benford's Law digit test.
359
+
360
+ Parameters
361
+ ----------
362
+ data : array-like or pandas.Series
363
+ The amounts to test. Non-numeric and non-finite values are dropped.
364
+ test : str or int
365
+ ``"first"``, ``"second"``, ``"first_two"`` (default, Nigrini's
366
+ workhorse test), ``"first_three"`` or ``"last_two"``. The integers
367
+ 1, 2, 3 mean first, first-two and first-three digits.
368
+ sign : {"positive", "negative", "both"}
369
+ Which records to keep. Analyse debits and credits separately where
370
+ possible - incentives to manipulate differ.
371
+ min_value : float, None or "auto"
372
+ Drop magnitudes below this value. ``"auto"`` follows Nigrini: no floor
373
+ for the first-digit test, 10 for second / first-two digits, 100 for
374
+ first-three digits. Use ``None`` to keep everything (this matches the
375
+ R package *benford.analysis*), e.g. for percentages such as returns.
376
+ decimals : int
377
+ Only for ``last_two``: scale amounts by ``10**decimals`` first, so
378
+ ``decimals=2`` tests the cents.
379
+ alpha : float
380
+ Significance level for per-digit z-tests (two-sided).
381
+ """
382
+ test = _resolve_test(test)
383
+ x, idx = clean_numeric(data, sign)
384
+ n_raw = x.size
385
+
386
+ floor = _DEFAULT_MIN_VALUE[test] if min_value == "auto" else min_value
387
+ if floor is not None:
388
+ keep = x >= floor
389
+ x, idx = x[keep], idx[keep]
390
+ if n_raw and keep.mean() < 0.5:
391
+ warnings.warn(
392
+ f"min_value={floor} removed {1 - keep.mean():.0%} of the records. "
393
+ "For small-scale data (percentages, ratios) pass min_value=None.",
394
+ stacklevel=2,
395
+ )
396
+
397
+ if test == "last_two":
398
+ digits, mask = last_two_digits(x, decimals)
399
+ x, idx = x[mask], idx[mask]
400
+ elif test == "second":
401
+ digits = leading_digits(x, 2) % 10
402
+ else:
403
+ digits = leading_digits(x, {"first": 1, "first_two": 2, "first_three": 3}[test])
404
+
405
+ n = int(x.size)
406
+ if n == 0:
407
+ raise ValueError("No records left to analyse after filtering.")
408
+
409
+ expected = expected_distribution(test)
410
+ counts = pd.Series(digits).value_counts().reindex(expected.index, fill_value=0)
411
+ actual = counts / n
412
+ diff = actual - expected
413
+ z = _z_stats(actual.to_numpy(), expected.to_numpy(), n)
414
+ z_crit = stats.norm.ppf(1 - alpha / 2)
415
+
416
+ table = pd.DataFrame(
417
+ {
418
+ "count": counts.astype(int),
419
+ "expected_count": expected * n,
420
+ "actual": actual,
421
+ "expected": expected,
422
+ "difference": diff,
423
+ "abs_difference": diff.abs(),
424
+ "z": z,
425
+ "significant": z > z_crit,
426
+ }
427
+ )
428
+
429
+ mad = float(diff.abs().mean())
430
+ exp_counts = expected.to_numpy() * n
431
+ chi2 = float(((counts.to_numpy() - exp_counts) ** 2 / exp_counts).sum())
432
+ chi2_df = len(expected) - 1
433
+ ks = float(np.abs(actual.cumsum() - expected.cumsum()).max())
434
+
435
+ df_value = distortion_factor(x) if test in ("first", "first_two", "first_three") else float("nan")
436
+
437
+ return BenfordResult(
438
+ test=test,
439
+ n=n,
440
+ n_excluded=n_raw - n,
441
+ table=table,
442
+ mad=mad,
443
+ conformity=_conformity(test, mad),
444
+ chi2=chi2,
445
+ chi2_df=chi2_df,
446
+ chi2_p=float(stats.chi2.sf(chi2, chi2_df)),
447
+ ks=ks,
448
+ ks_critical=1.36 / np.sqrt(n),
449
+ distortion_factor=df_value,
450
+ alpha=alpha,
451
+ values=pd.Series(x, index=idx, name="value"),
452
+ digits=pd.Series(digits, index=idx, name="digits"),
453
+ )
454
+
455
+
456
+ def summation_test(data, sign: str = "positive", min_value: Optional[float] = 10.0) -> pd.DataFrame:
457
+ """Nigrini's summation test on first-two digits.
458
+
459
+ Under Benford the *sums* of amounts in each first-two-digit group are
460
+ roughly equal (1/90 of the total each). A group with an outsized share
461
+ points at a few very large amounts, or many repeated mid-sized ones,
462
+ beginning with those digits. Returns one row per group, sorted by excess.
463
+ """
464
+ x, _ = clean_numeric(data, sign)
465
+ if min_value is not None:
466
+ x = x[x >= min_value]
467
+ if x.size == 0:
468
+ raise ValueError("No records left to analyse after filtering.")
469
+ d = leading_digits(x, 2)
470
+ sums = pd.Series(x).groupby(d).sum().reindex(range(10, 100), fill_value=0.0)
471
+ total = sums.sum()
472
+ out = pd.DataFrame(
473
+ {
474
+ "sum": sums,
475
+ "actual": sums / total,
476
+ "expected": 1 / 90,
477
+ }
478
+ )
479
+ out.index.name = "digits"
480
+ out["difference"] = out["actual"] - out["expected"]
481
+ out["ratio_to_expected"] = out["actual"] / out["expected"]
482
+ return out.sort_values("difference", ascending=False)
483
+
484
+
485
+ def number_duplication(data, sign: str = "positive", decimals: int = 2, top: Optional[int] = 20) -> pd.DataFrame:
486
+ """Most frequently repeated exact amounts (Nigrini's number duplication test).
487
+
488
+ Repeated amounts are often legitimate (recurring fees, standard prices),
489
+ but the top of this list is where duplicate payments, split purchases and
490
+ made-up figures tend to sit.
491
+ """
492
+ x, _ = clean_numeric(data, sign)
493
+ rounded = pd.Series(np.round(x, decimals))
494
+ counts = rounded.value_counts()
495
+ out = pd.DataFrame({"count": counts})
496
+ out.index.name = "amount"
497
+ out["share"] = out["count"] / len(rounded)
498
+ out["first_two"] = leading_digits(out.index.to_numpy(dtype=float), 2) if len(out) else []
499
+ out["total"] = out.index.to_numpy(dtype=float) * out["count"]
500
+ out = out.sort_values(["count", "total"], ascending=False)
501
+ return out if top is None else out.head(top)
502
+
503
+
504
+ def second_order(data, sign: str = "positive", decimals: int = 2, alpha: float = 0.05) -> BenfordResult:
505
+ """Nigrini's second-order test.
506
+
507
+ Sort the amounts, take the differences between neighbours and run the
508
+ first-two-digits test on those differences. Almost any data set - even
509
+ one that should not follow Benford - produces Benford-like differences,
510
+ so spikes here point at rounding, data-entry or processing errors.
511
+ ``decimals`` rounds the differences to strip floating-point noise.
512
+ """
513
+ x, _ = clean_numeric(data, sign)
514
+ gaps = np.round(np.diff(np.sort(x)), decimals)
515
+ gaps = gaps[gaps > 0]
516
+ if gaps.size == 0:
517
+ raise ValueError("All values are identical; no second-order differences.")
518
+ return benford(gaps, test="first_two", min_value=None, alpha=alpha)