仕訳データから異常を検出する|Benford分析・休日仕訳・承認限度額の直下
/ 14 min read
Table of Contents
はじめに
仕訳の全件チェックは、Excelでは現実的ではありません。数万件の明細を目視で追うことはできないので、実務では異常の兆候がある仕訳だけを機械的に絞り込んで、そこを人が見るという進め方をします。
この記事では、会計監査で使われる異常検出の手法をpandasで実装します。
- Benfordの法則による先頭桁の分析
- 承認限度額の直下に金額が張り付いていないか
- 休日に計上された仕訳
- 端数のない金額の多発
- 同一金額の繰り返し
コードはpandas 3.0で実際に動かして、出力を確認しています。仕訳データの読み込みと集計は仕訳データをpandasで集計するに書いたので、この記事では検出の部分だけを扱います。
Benfordの法則による先頭桁の分析
何をする手法か
自然に発生した数値データの先頭桁は、1から9まで均等(各11.1%)にはならず、**1が約30%、9が約4.6%**という偏った分布になります。これがBenfordの法則です。
先頭桁が d になる確率は次の式で表されます。
P(d) = log10(1 + 1/d)売上、経費、支払額のように、桁をまたいで広く分布する会計数値は、この法則によく従います。逆に、人為的に作られた数値はこの分布から外れやすいため、その乖離を手がかりにします。
実装
import numpy as npimport pandas as pd
digits = np.arange(1, 10)expected = pd.Series(np.log10(1 + 1 / digits), index=digits)
def first_digit(s: pd.Series) -> pd.Series: """金額の先頭桁を取り出す""" return s.abs().astype(str).str.lstrip("0.").str[0].astype(int)
def benford(s: pd.Series) -> tuple[pd.DataFrame, float]: actual = first_digit(s).value_counts(normalize=True).reindex(digits, fill_value=0) comp = pd.DataFrame({"実測": actual, "期待": expected}) comp["差"] = comp["実測"] - comp["期待"] mad = comp["差"].abs().mean() # 平均絶対偏差 return comp, madlstrip("0.") を挟んでいるのは、0.05 のような1未満の値でも「5」を取り出すためです。
通常のデータで試す
対数一様分布から作った3,000件のデータに適用すると、実測が期待値にきれいに乗ります。
| 先頭桁 | 実測 | 期待 | 差 |
|---|---|---|---|
| 1 | 0.2980 | 0.3010 | -0.0030 |
| 2 | 0.1737 | 0.1761 | -0.0024 |
| 3 | 0.1253 | 0.1249 | 0.0004 |
| … | … | … | … |
| 9 | 0.0547 | 0.0458 | 0.0089 |
このときのMAD(平均絶対偏差)は 0.0023 でした。
MADで判定する
差の絶対値の平均を見て、適合しているかを判断します。実務でよく使われる目安は次のとおりです。
| MAD | 評価 |
|---|---|
| 〜0.006 | 適合している |
| 0.006〜0.012 | おおむね適合 |
| 0.012〜0.015 | やや逸脱 |
| 0.015〜 | 逸脱している |
不正のパターンを混ぜてみる
「100万円以上は上長承認が必要」というルールがある会社で、それを避けるために95万〜99万円台の支出が繰り返されている、という想定でデータを作ります。全体の10%を混ぜました。
rng = np.random.default_rng(42)
natural = np.floor(10 ** rng.uniform(2, 6, 2700)).astype(int)just_under = rng.integers(950_000, 999_999, 300) # 承認限度額の直下mixed = np.concatenate([natural, just_under])
comp, mad = benford(pd.Series(mixed))結果です。
| 先頭桁 | 実測 | 期待 | 差 |
|---|---|---|---|
| 1 | 0.2683 | 0.3010 | -0.0327 |
| 2 | 0.1580 | 0.1761 | -0.0181 |
| … | … | … | … |
| 8 | 0.0450 | 0.0512 | -0.0062 |
| 9 | 0.1480 | 0.0458 | +0.1022 |
MADは 0.0227 となり、逸脱の水準に入りました。先頭桁9が期待4.6%に対して実測14.8%と、3倍以上に膨らんでいます。
先頭桁ごとの差を見ることで、どの桁で何が起きているかまで絞り込めるのがこの手法の利点です。
Benford分析が使えないケース
MADが大きいからといって、すぐに問題があると考えてはいけません。そもそもBenfordの法則が当てはまらないデータが、会計の中には多くあります。
- 範囲が限定されている数値 … 単価が5,000〜9,000円に収まる商品の売上、上限のある手当など
- 採番された番号 … 伝票番号、取引先コード、請求書番号
- 人為的に決められた金額 … 月額固定の家賃、リース料、定額の顧問報酬
- 件数が少ないデータ … 目安として500件を下回ると、偶然のばらつきで簡単にMADが跳ねます
固定費の仕訳をまとめてBenford分析にかけると、当然のように逸脱します。変動する取引に絞って適用するのが前提です。
承認限度額の直下を直接見る
Benford分析は全体の傾向を見る手法なので、特定の閾値まわりを調べたいなら直接数えるほうが確実です。
社内規程の承認限度額(100万円、50万円、10万円など)を挟んで、前後の件数を比べます。
def around_threshold(s: pd.Series, threshold: int, width: int) -> None: under = s[(s >= threshold - width) & (s < threshold)] over = s[(s >= threshold) & (s < threshold + width)] print(f"{threshold:,}円の直下 {width:,}円以内: {len(under)}件") print(f"{threshold:,}円の直上 {width:,}円以内: {len(over)}件")
around_threshold(pd.Series(mixed), 1_000_000, 100_000)先ほどのデータでは、こうなりました。
1,000,000円の直下 100,000円以内: 338件1,000,000円の直上 100,000円以内: 0件金額の分布は本来なめらかなので、閾値を境に件数が急変するのは自然ではありません。「直下だけが多く、直上がほとんど無い」という形は、承認を回避するために金額を調整した可能性を示します。
休日に計上された仕訳
通常の業務時間外に計上された仕訳を抜き出します。
df["曜日"] = df["計上日"].dt.dayofweek # 0=月曜, 6=日曜holiday = df[df["曜日"] >= 5] # 土日
print(f"土日計上: {len(holiday)}件 / {len(df)}件")print(holiday["計上日"].dt.day_name().value_counts().to_dict())祝日も含めたい場合は jpholiday を使います。
$ pip install jpholidayimport jpholiday
df["祝日"] = df["計上日"].apply(jpholiday.is_holiday)off_days = df[(df["曜日"] >= 5) | df["祝日"]]タイムスタンプ(登録日時)が取れるなら、深夜帯の登録も見ておきます。
df["時刻"] = df["登録日時"].dt.hourmidnight = df[(df["時刻"] >= 22) | (df["時刻"] < 6)]端数のない金額と同一金額の繰り返し
見積りで計上した金額や、実態のない取引は、切りのよい数字になりやすい傾向があります。
# 1万円単位で割り切れる金額round_amt = df[df["金額"] % 10_000 == 0]print(f"{len(round_amt)}件 = {len(round_amt) / len(df):.1%}")
# 同じ金額が3回以上出てくるものdup = df["金額"].value_counts()print(dup[dup >= 3].head())出力例です。
20件 = 4.0%500000 101000000 7100000 3税込の取引であれば端数が出るのが普通なので、消費税がかかる取引で1万円単位ちょうどが多いのは不自然です。同一金額の繰り返しとあわせて見ると、絞り込みの精度が上がります。
起票者別に見る
誰が起票したかで層別すると、特定の担当者に偏りがないかを確認できます。
df.groupby("起票者")["金額"].agg(["count", "mean", "max", "sum"])金額の平均や最大値が突出している起票者がいれば、権限の設定が適切かを確認する材料になります。職務分掌の評価にそのまま使える切り口です。
見つけたあとにどうするか
ここが実務でいちばん大事なところです。
**検出された仕訳の大半は、正当な取引です。**Benford分析でMADが大きくても、単に固定費が多いだけかもしれません。休日の仕訳も、決算作業なら当たり前です。承認限度額の直下が多いのも、その金額帯の取引が実際に多い業種であればおかしくありません。
これらの手法が果たす役割は、数万件から数十件に絞り込むことです。絞り込んだ先で行うのは、証憑を見て、担当者に理由を聞き、業務の実態と照らすという、結局は人の作業です。
おわりに
これらの手続は、一度スクリプトにしてしまえば毎期そのまま流せます。手作業で全件を見ることは不可能でも、機械的な絞り込みであれば全件を対象にできる、というのが自動化のいちばんの価値です。
まずはBenford分析よりも、休日仕訳と端数のない金額から始めるのがおすすめです。理屈が単純で、結果の説明もしやすく、会計データが手元にあればすぐ動かせます。
