監査サンプリングをPythonで実装する|無作為抽出・系統抽出・金額単位抽出
/ 13 min read
Table of Contents
はじめに
売掛金の残高確認、経費の証憑突合、在庫の実地棚卸。母集団が数百件から数万件ある手続で、全件を見ることは現実的ではありません。そこで一部を抽出して、その結果から全体を推し量ります。
抽出の方法は、Excelの乱数関数と手作業でもできます。ただ、後から同じ結果を再現できるかという点で、その方法には無理があります。RAND() は再計算のたびに値が変わりますし、並べ替えて上位を取る手順を調書に残すのも簡単ではありません。
この記事では、監査で使う3つのサンプリング手法をpandasで実装します。コードはpandas 3.0で実際に動かして、出力を確認しています。
- 単純無作為抽出
- 系統抽出
- 金額単位抽出(MUS)
母集団
売掛金の残高明細を例にします。実務に近づけるため、少数の大口と多数の小口が混在する分布にしました。
import numpy as npimport pandas as pd
df = pd.read_csv("receivables.csv") # 取引先コード, 残高
total = df["残高"].sum()print(f"母集団 {len(df)}件 / 合計 {total:,}円")母集団 800件 / 合計 251,043,971円平均残高は313,805円ですが、上位数社が数千万円を占めています。売掛金や仕入債務は、たいていこの形になります。
1. 単純無作為抽出
全件から等しい確率で抽出します。
sample = df.sample(n=30, random_state=42)random_state を指定するのが重要です。指定しないと実行のたびに違うサンプルが出てしまい、調書として成立しません。
もっとも単純で説明しやすい方法ですが、金額の大小を考慮しないという弱点があります。
2. 系統抽出
母集団を並べ、一定の間隔で抜き出します。
n_sample = 30k = len(df) // n_sample # 抽出間隔rng = np.random.default_rng(42)start = rng.integers(0, k) # 開始位置を無作為に決める
sample = df.iloc[start::k].head(n_sample)開始位置だけを乱数で決め、あとは等間隔で機械的に選びます。実施が容易で、母集団に並び順の意味がある場合(伝票番号順など)に全体へ広く散らせるのが利点です。
3. 金額単位抽出(MUS)
監査実務でよく使われるのがこれです。Monetary Unit Sampling、確率比例抽出とも呼ばれます。
考え方
母集団を「件数の集まり」ではなく「金額(1円玉)の集まり」とみなします。合計251,043,971円を、251,043,971個の1円玉が並んでいると考え、そこから等間隔に1円玉を抜き、その1円玉が属する取引先をサンプルとします。
結果として、**残高が大きい取引先ほど抽出される確率が高くなります。**金額が大きいところに誤りがあれば影響も大きいので、監査の目的に合った抽出方法です。
実装
def mus(df: pd.DataFrame, amount_col: str, n_sample: int, seed: int) -> pd.DataFrame: """金額単位抽出(MUS)""" rng = np.random.default_rng(seed)
d = df.sort_values("取引先コード").copy() # 並び順を固定する d["累計"] = d[amount_col].cumsum()
interval = d[amount_col].sum() / n_sample # サンプリング区間 start = rng.uniform(0, interval) # 開始点を無作為に決める points = start + np.arange(n_sample) * interval
idx = np.searchsorted(d["累計"].values, points) idx = idx[idx < len(d)] return d.iloc[np.unique(idx)]
sample = mus(df, "残高", n_sample=30, seed=20260830)cumsum() で累計残高を作り、等間隔に置いた抽出点が「どの取引先の区間に落ちるか」を searchsorted で調べています。
結果
サンプリング区間 = 251,043,971 / 30 = 8,368,132円
MUS抽出: 20件 / 金額カバー率 77.4%平均残高: 母集団 313,805円 / 抽出分 9,721,195円同じ母集団から単純無作為で20件を抽出した場合の金額カバー率は**0.7%でした。MUSでは77.4%**です。同じ手間で、検証できる金額が100倍以上違います。
区間を超える項目は必ず選ばれる
MUSには重要な性質があります。サンプリング区間(この例では8,368,132円)以上の残高を持つ項目は、必ず抽出されます。
区間より大きい項目は、等間隔に置かれた抽出点を必ず1つ以上またぐためです。今回のデータでは区間以上の取引先が7件あり、その全件が抽出されていることを確認しました。この7件だけで母集団の59.8%を占めます。
「大口は全部見て、残りはサンプリングする」という実務の感覚が、手続の中に自動的に組み込まれているわけです。大口を別枠で抜き出す作業を別途行う必要がありません。
件数が指定より少なくなる理由
30件を指定したのに、抽出結果は20件でした。これは異常ではありません。
区間を大きく超える項目は、複数の抽出点を同時にまたぎます。上の実装では np.unique() で重複を除いているため、その分だけ件数が減ります。
idx = np.unique(idx) # 同じ項目が複数回選ばれた分を1件にまとめる重複を除かず「同じ項目を複数回選んだものとして扱う」方法もあります。評価の段階でどう扱うかによるので、採用した方法を調書に明記しておいてください。
再現性を確保する
監査手続では、あとから第三者が同じ結果を再現できることが求められます。サンプリングで押さえるべきは次の3点です。
SEED = 20260830 # 1. 乱数シードを固定して記録するN_SAMPLE = 30 # 2. サンプル件数
df = df.sort_values("取引先コード") # 3. 母集団の並び順を固定するsample = mus(df, "残高", N_SAMPLE, SEED)同じシードなら何度実行しても同じサンプルが得られることは、実際に確認しておくとよいです。
a = mus(df, "残高", 30, 12345)["取引先コード"].tolist()b = mus(df, "残高", 30, 12345)["取引先コード"].tolist()assert a == b抽出結果は、母集団の情報とあわせて保存します。
sample.to_excel("sampling_result.xlsx", index=False)
print(f"母集団件数: {len(df)}")print(f"母集団合計: {df['残高'].sum():,}")print(f"シード: {SEED} / 指定件数: {N_SAMPLE} / 実際の抽出件数: {len(sample)}")print(f"金額カバー率: {sample['残高'].sum() / df['残高'].sum():.1%}")どれを使うか
| 手法 | 向いている場面 | 弱点 |
|---|---|---|
| 単純無作為 | 金額の分布が均一、件数の属性を見たい | 大口を取りこぼす |
| 系統抽出 | 並び順に意味がある、実施を簡便にしたい | 周期性があると偏る |
| MUS | 金額の妥当性を見たい(残高確認、評価) | 少額の項目が選ばれにくい |
過大計上を見つけたいならMUSが基本になります。一方で、MUSは金額の小さい項目がほとんど選ばれないため、**簿外債務のような「計上漏れ」の検出には向きません。**残高がゼロや少額の項目にこそ問題がある場合は、別の方法で母集団を選ぶ必要があります。
おわりに
サンプリングは、一度スクリプトにしておけば毎期そのまま使えます。シードと件数をパラメータにしておけば、対象が変わっても同じコードで回せます。
Excelの乱数で抽出していると、再現性の説明にいつも困ることになります。シードを記録するだけでその問題が消えるというのが、コードで実施する一番の利点だと思います。
