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 +60 -0
- ledgerlens/_utils.py +90 -0
- ledgerlens/benford.py +518 -0
- ledgerlens/cli.py +154 -0
- ledgerlens/datasets.py +126 -0
- ledgerlens/jet.py +357 -0
- ledgerlens/report.py +179 -0
- ledgerlens/scores.py +266 -0
- ledgerlens-0.1.2.dist-info/METADATA +240 -0
- ledgerlens-0.1.2.dist-info/RECORD +14 -0
- ledgerlens-0.1.2.dist-info/WHEEL +5 -0
- ledgerlens-0.1.2.dist-info/entry_points.txt +2 -0
- ledgerlens-0.1.2.dist-info/licenses/LICENSE +21 -0
- ledgerlens-0.1.2.dist-info/top_level.txt +1 -0
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)
|