乙Py先生のプログラミング教室
初学者のためのプログラミング学習サイト

Python

今回はPythonの闇挙動についてです。


解説動画はこちら


 
Pythonの闇挙動10選


Pythonにはその独特の仕様があり
コードの書き方で思わぬ挙動を引き起こします。

そんな
変てこりんな挙動を10選んでみました。



1.is演算子の罠
(256 == 256 はTrueでも 257 == 257 はFalse!?)
a = 256
b = 256
print(a is b)  # True

c = 257
d = 257
print(c is d)  # True or False !?!?
True
False


Pythonは 小さい整数(-5~256)のオブジェクトを
キャッシュ する最適化を行っています。

そのため、a と b は同じオブジェクトを参照しており
is が True になります。

しかし、257 はキャッシュ対象外なので
c と d は別のオブジェクトとなり is が False になります。

対策:

値の比較には is ではなく == を使うべき





2. ミュータブルなデフォルト引数の罠

ミュータブル :
作成後にもその状態を変えることの出来るオブジェクトのこと

イミュータブル :
作成後にその状態を変えることのできないオブジェクトのこと

デフォルト引数に空のリストを設定した
関数を作ります。

これを実行してみると
# デフォルト引数に空のリストを設定した関数
def append_to_list(value, my_list=[]):
    my_list.append(value)
    return my_list

print(append_to_list(1))  # [1]
print(append_to_list(2))  # [1, 2] (???)
print(append_to_list(3))  # [1, 2, 3] (!!!)
[1]
[1, 2]
[1, 2, 3]


Pythonの関数のデフォルト引数は
関数が定義されたときに評価 され
一度作られたオブジェクトが 再利用 されます。

そのため my_list は毎回新しくなるわけではなく
前回の呼び出し時の変更が次の呼び出しに影響を与えます。

対策:

デフォルト引数にはミュータブルな値
(list, dict など)を使わず、None を使うようにする




3. += と + の違い(リストの参照問題)

リストを代入して新しいリストを作って
元のリストに要素を加えると...
# a のリストをコピーして新しいリスト b を作ってみよう
a = [1, 2, 3]
b = a
a += [4, 5]
print(a)  # [1, 2, 3, 4, 5]
print(b)  # [1, 2, 3] or [1, 2, 3, 4, 5] ?!?
[1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]


a += [4, 5] は a.extend([4, 5]) のように
リスト自体を変更 するため
b も同じオブジェクトを参照しているので
b にも変更が反映される

対策:

新しいリストを作成したい場合は + を使う
a = [1, 2, 3]
b = a
a = a + [4, 5]  # 新しいリストが作られる
print(a)  # [1, 2, 3, 4, 5]
print(b)  # [1, 2, 3] (bは変更されない)
[[1, 2, 3, 4, 5]
[1, 2, 3]



4. sorted() と sort() の違いに注意

リストの並び替えを行う方法は大きく2種類あります
a = [3, 1, 2]
print(sorted(a))  # [1, 2, 3]
print(a)  # [1, 2, 3] or [3, 1, 2] (???)

a.sort()
print(a)  # [1, 2, 3]
[1, 2, 3]
[3, 1, 2]
[1, 2, 3]



sorted(a) は 新しいリストを返す ため
a 自体は変更されない

a.sort() は リストを直接変更する ため
a の内容が書き換わる

対策:

リストをそのまま並び替えたい場合は リスト.sort()
元のリストを残したい場合は sorted(リスト) を使う




5. 0.1 + 0.2 == 0.3 が False になる!?

Pythonで小数点の値を比較すると...
print(0.1 + 0.2 == 0.3)  # False (???)
print(0.1 + 0.2)  # 0.3 ?!?!
False
0.30000000000000004


浮動小数点の計算誤差が原因。
0.1 や 0.2 は 2進数で正確に表現できないため
足すと誤差が生じる。

対策:

誤差を考慮して math.isclose(値 , 比較値) を使う
import math
print(math.isclose(0.1 + 0.2, 0.3))  # True
True



6."" or "Hello" は "Hello" なのに "0" or "Hello" は "0" になる!?

print("" or "Hello")  # Hello
print("0" or "Hello")  # 0 or "Hello" (???)
Hello
0



or は 最初に「真」と評価された値を返すため
""(空文字)は False なので "Hello" が返る

しかし "0" は 非空の文字列であり True と評価される ため
そのまま "0" が返る。

対策:

文字列の比較をする場合は bool(value) を明示的に使う




7.sum() で文字列を合計するとエラーになるのに max() は動く!?

print(max(["a", "b", "c"]))  # c
c
print(sum(["a", "b", "c"]))  # TypeError (???)
TypeError


sum() は 数値の合計を計算する関数 なので
文字列を足そうとするとエラーになる

max() は 「大きい方を返す」関数 なので
辞書順で "c" を返す

対策:

文字列の連結には sum() ではなく
"".join() を使う





8.range() の「スタート」には 0 が入るのに slice() には入らない!?

print(list(range(5)))  # [0, 1, 2, 3, 4]

print("hello"[slice(5)])  # hello (???)
print("hello"[slice(None, 5)])  # hello (???)
print("hello"[slice(5, None)])  # (空文字)
[0, 1, 2, 3, 4]
hello
hello



range(5) は デフォルトの開始値が 0 になるので
[0, 1, 2, 3, 4] になる

slice(5) は デフォルトの開始値が None になり
slice(None, 5) と解釈される

slice(5, None) は 5 以降の文字を取得しようとするが
範囲外なので空文字になる




9.set の順番がランダムに見える!?

文字列のデータをSET型のデータにしてみると
s = set("hello world")
print(s)
{'r', 'd', 'o', 'l', 'h', 'e', 'w', ' '}

set は 順序を保持しないデータ構造 のため
出力される順番は内部のハッシュ値によって変わる

対策:

順番を維持したいなら set ではなく
OrderedDict や list を使う

from collections import OrderedDict
ordered_set = "".join(OrderedDict.fromkeys("hello world"))
print(ordered_set)
helo wrd



10. dict.keys() の結果は list じゃない!?

d = {"a": 1, "b": 2}

print(d.keys())  # dict_keys(['a', 'b'])
print(type(d.keys()))  # 
print(list(d.keys()))  # ['a', 'b'] (明示的にリスト化)
dict_keys(['a', 'b'])
< cla ss 'dict_keys'>
['a', 'b']

d.keys() は 「ビューオブジェクト」 であり
リストではない
そのため、リストと同じように扱えないことがある

対策:

リストとして扱いたい場合は
list(d.keys()) を使う




まとめ

リストの操作や比較演算子周りには
意外と知られていない挙動が多い

1文字違うだけで別の挙動になったり
操作の順番で意図しない結果になったりする

細かい仕様を把握する必要ありますねー

ということで
今回はバグを生みやすい
Pythonの変な挙動10選についてでした。

それでは。

今回は最近流行りのポーカー
に関する確率のシミュレーションです

解説動画はこちら


 

ポーカーについて

ポーカーはトランプ5枚の手札の組み合わせで
役を作るゲームです。

1.ハイカード(強いカードの所持 2 < A)
2.ワンペア(同じ数字の組み合わせが1つ)
3.ツーペア(同じ数字の組み合わせが2つ)
4.スリーカード(同じ数字3つ)
5.ストレート(2,3,4,5,6 などの数字の並び)
6.フラッシュ(同じスートの組み合わせ HDCS)
7.フルハウス(スリーカードに加えて、同じ数字の組み合わせが1つ)
8.フォーカード(同じ数字4つ)
9.ストレートフラッシュ(ストレート + フラッシュ)
10.ロイヤルフラッシュ(AKQJTのフラッシュ)


テキサスホールデムルール


ポーカーのルールの一つで
プレイヤーそれぞれに配られた2枚のカードと
プレイヤー全員が共有する公開された
コミュニティカード"枚の計7枚で役を作り
チップをベットするなどの駆け引きを行うゲームルールです
(ベット周りの詳細なルールは割愛)


ここからはテキサスホールデムの
初期手札による勝率がどうなるのかを
検証するシミュレーションプログラムについてです。




役を計算するプログラム

シミュレーションを行うには
ポーカーの役の判定を行うプログラムが必要ですね

import random
from collections import Counter
from itertools import combinations

# ポーカーの役
POKER_HANDS = {
    0: "ハイカード",
    1: "ワンペア",
    2: "ツーペア",
    3: "スリーカード",
    4: "ストレート",
    5: "フラッシュ",
    6: "フルハウス",
    7: "フォーカード",
    8: "ストレートフラッシュ",
    9: "ロイヤルストレートフラッシュ"
}

# カードのランクとスート(HA=ハートのエース, D2=ダイヤの2)
RANKS = "23456789TJQKA"
SUITS = "HDSC"  # ハート, ダイヤ, スペード, クラブ
DECK = [s + r for r in RANKS for s in SUITS]

# 役の評価(スコアをタプルで返す)
def evaluate_hand(hand):
    ranks = sorted([RANKS.index(c[1]) for c in hand], reverse=True)
    suits = [c[0] for c in hand]
    rank_counts = Counter(ranks)
    flush = len(set(suits)) == 1
    straight = len(rank_counts) == 5 and (max(ranks) - min(ranks) == 4 or set(ranks) == {12, 3, 2, 1, 0})  # A-2-3-4-5対応

    # 役の判定(同率はRankで比較)
    if straight and flush:
        if set(ranks) == {12, 11, 10, 9, 8}:  # A, K, Q, J, 10
            return (9, max(ranks))  # ロイヤルストレートフラッシュ
        return (8, max(ranks)) # ストレートフラッシュ
    if 4 in rank_counts.values():
        return (7, max(k for k, v in rank_counts.items() if v == 4))  # フォーカード
    if 3 in rank_counts.values() and 2 in rank_counts.values():
        return (6, max(k for k, v in rank_counts.items() if v == 3))  # フルハウス
    if flush:
        return (5, ranks)  # フラッシュ
    if straight:
        return (4, max(ranks))  # ストレート
    if 3 in rank_counts.values():
        return (3, max(k for k, v in rank_counts.items() if v == 3))  # スリーカード
    if list(rank_counts.values()).count(2) == 2:
        return (2, sorted([k for k, v in rank_counts.items() if v == 2], reverse=True))  # ツーペア
    if 2 in rank_counts.values():
        return (1, max(k for k, v in rank_counts.items() if v == 2))  # ワンペア
    return (0, ranks)  # ハイカード

これを用いてシミュレーションを行なっていきます。



テキサスホールデムのシミュレーション

初期手札2枚と公開札5枚で、役を作り、勝率がどうなるか
4人で対戦をする際の勝率を出す
シミュレーションプログラムです。

user_num のところが対戦人数になるので
変更すれば、その人数での確率を求めることができます。
# モンテカルロ法で勝率計算
def monte_carlo_win_rate(my_hand, num_simulations=10000):
    wins = 0
    user_num = 3
    for _ in range(num_simulations):
        deck = DECK.copy()
        for card in my_hand:
            deck.remove(card)

        # 4人分の手札をランダムに配る
        random.shuffle(deck)
        opponent_hands = [deck[i * 2: (i + 1) * 2] for i in range(user_num)]
        community_cards = deck[6:11]

        # 各プレイヤーのベストハンドを評価
        my_best = max(evaluate_hand(list(comb)) for comb in combinations(my_hand + community_cards, 5))
        opponent_best = [max(evaluate_hand(list(comb)) for comb in combinations(hand + community_cards, 5)) for hand in opponent_hands]

        # 自分が最も強い手を持っているか判定
        if my_best > max(opponent_best):
            wins += 1

    return wins / num_simulations

# 例: 自分の手札をセットして勝率を計算
my_hand = ["HA", "HK"]  # ハートのエース・キング
win_rate = monte_carlo_win_rate(my_hand, num_simulations=5000)
print(f"勝率: {win_rate:.2%}")
勝率: 33.02%

カードの組み合わせを変えれば
その都度計算が行えます。



初期カードの組み合わせでの勝率

2種の13 * 13 枚の組み合わせにおける
勝率を計算してみましょう。

RANKS = "23456789TJQKA"
H_DECK = [s + r for r in RANKS for s in "H"]
D_DECK = [s + r for r in RANKS for s in "D"]
combi = [[h, d] for h in H_DECK for d in D_DECK]
result = {":".join(c):0 for c in combi}
for my_hand in combi:
  result[":".join(my_hand)] = monte_carlo_win_rate(my_hand, num_simulations=5000)

これで、13x13=169通りの結果が出せます。

これをヒートマップにしてみましょう。


勝率をヒートマップにする


import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

# キーのソート用の関数
def get_rank_order(card):
    return RANKS.index(card[1])

# キーを分離してデータフレームを作成
data_list = []
for key, value in result.items():
    row_key, col_key = key.split(':')
    data_list.append({'row': row_key, 'col': col_key, 'value': value})

df = pd.DataFrame(data_list)
unique_rows = sorted(df['row'].unique(), key=get_rank_order)
unique_cols = sorted(df['col'].unique(), key=get_rank_order)

# ピボットテーブルを作成
pivot_df = df.pivot(index='row', columns='col', values='value')

# インデックスと列を順序通りに並び替え
pivot_df = pivot_df.reindex(index=unique_rows, columns=unique_cols)

# ヒートマップの作成
plt.figure(figsize=(10, 8))
sns.heatmap(pivot_df,
            cmap='RdYlGn',
            vmin=0,
            vmax=1,
            annot=True,
            fmt='.2f',
            cbar_kws={'label': 'Value'})

plt.title('Win rate for first hand combination')
plt.tight_layout()
plt.show()
heatmap

やはり、最初にワンペアを持っているだけで勝率は高くなりますね
とはいえ、それだけでは勝てないのが
このテキサスホールデムルールの面白いところ

初期手札の読み合いやベットなどの駆け引き
この辺りが組み合わさることで
ゲーム性が高くかなり面白いものになっています。

とはいえ
日本ではまだまだ、カジノがないので
ポーカーを楽しむには
単純なゲームとしてのポーカーしか出来ません

オンラインで展開されているものは
ほぼ日本国内では違法ではあるので
手を出さないように気を付けないといけません!!!

カジノが出来たら
もっともっとシミュレーションしましょう。

今回はここまでです
それでは

今回は水曜日のダウンタウン企画
電気椅子ゲームのシミュレーションです。


解説動画はこちら

 

電気椅子ゲームとは

水曜日のダウンタウンのネタ
結構色々やっていますが
今回は電気椅子ゲームの
シミュレーションです。

ゲームのルールはこうです。


1~12などの番号が書かれたイス
プレイヤー二人は交互に以下を行う
相手が座るイスを予想し、電流を仕掛ける
電気イスでなければ得点獲得し、イスは撤去される
電流イスに座ると得点没収され、イスはそのまま

最終的にポイントが高い方が勝利
3回電流を喰らうか、40点先取されたら負け

このルールでシミュレーション用の
コードを作ってみました。
import random

headers = ["ターン数", "選択プレイヤー", "選択数字", "電気プレイヤー", "仕掛数字", "結果", "P1得点", "P2得点", "P1電気回数", "P2電気回数", "残り椅子"]

class ElectricChairGame:
    def __init__(self):
        self.chairs = {i: i for i in range(1, 13)}
        self.player_scores = [0, 0]
        self.electric_fails = [0, 0]
        self.current_player = 0
        self.electrified_chair = None

    def choose_chair(self, chair_number):
        # 選択した数字と電気椅子の数字が一致する場合のみ電気椅子確定
        if chair_number == self.electrified_chair:
            self.electric_fails[self.current_player] += 1
            self.player_scores[self.current_player] = 0
            return "電気椅子"
        else:
            points = self.chairs[chair_number]
            self.player_scores[self.current_player] += points
            del self.chairs[chair_number]
            return "得点獲得"

    def set_electric(self, chair_number):
        self.electrified_chair = chair_number
        return True

    def check_game_end(self):
        message = ["", ""]
        for player in range(2):
            if self.electric_fails[player] >= 3:
                winner = 2 if player == 0 else 1
                message = [winner, "電気椅子3回"]
                return True, message
            if self.player_scores[player] >= 40:
                winner = player + 1
                message = [winner, "スコア40"]
                return True, message

        if len(self.chairs) == 1:
            winner = 1 if self.player_scores[0] > self.player_scores[1] else 2
            message = [winner, "スコア差"]
            return True, message

        return False, message

def simulate_game(echo=False):
    game = ElectricChairGame()
    turn,selecting_player = 1,1
    results = []
    while True:
        # 電気椅子設置
        electric_player = 2 if selecting_player == 1 else 1
        electric_choice = random.choice(list(game.chairs.keys()))
        game.set_electric(electric_choice)

        # 椅子選択
        chair_choice = random.choice(list(game.chairs.keys()))
        game.current_player = selecting_player - 1
        result = game.choose_chair(chair_choice)

        # ログ出力
        log_line = [
            turn,
            selecting_player,
            chair_choice,
            electric_player,
            electric_choice,
            result,
            game.player_scores[0],
            game.player_scores[1],
            game.electric_fails[0],
            game.electric_fails[1],
            sorted(list(game.chairs.keys()))
        ]
        results.append(log_line)
        # ゲーム終了判定
        game_end, message = game.check_game_end()
        if game_end:
          if message[0]==1:
              win_score = game.player_scores[0]
          else:
              win_score = game.player_scores[1]
          message+=[turn ,
            win_score,
            game.player_scores[0],
            game.player_scores[1],
            game.electric_fails[0],
            game.electric_fails[1],
            len(list(game.chairs.keys()))]
          if echo:
            print(", ".join(headers))
            for log_line in results:
              print(log_line)
            print(f"ゲーム終了: 勝利プレイヤー : {message[0]}, 勝因 : {message[1]}")
          return message, results

        # プレイヤー交代
        selecting_player = 2 if selecting_player == 1 else 1
        turn += 1

ゲームを実行する場合はこうです
message, log_line = simulate_game(True)
ターン数, 選択プレイヤー, 選択数字, 電気プレイヤー,
仕掛数字, 結果, P1得点, P2得点, P1電気回数, P2電気回数, 残り椅子
[1, 1, 2, 2, 3, '得点獲得', 2, 0, 0, 0, [1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]]
[2, 2, 11, 1, 5, '得点獲得', 2, 11, 0, 0, [1, 3, 4, 5, 6, 7, 8, 9, 10, 12]]
[3, 1, 6, 2, 7, '得点獲得', 8, 11, 0, 0, [1, 3, 4, 5, 7, 8, 9, 10, 12]]
[4, 2, 1, 1, 8, '得点獲得', 8, 12, 0, 0, [3, 4, 5, 7, 8, 9, 10, 12]]
[5, 1, 3, 2, 10, '得点獲得', 11, 12, 0, 0, [4, 5, 7, 8, 9, 10, 12]]
[6, 2, 12, 1, 7, '得点獲得', 11, 24, 0, 0, [4, 5, 7, 8, 9, 10]]
[7, 1, 8, 2, 4, '得点獲得', 19, 24, 0, 0, [4, 5, 7, 9, 10]]
[8, 2, 9, 1, 7, '得点獲得', 19, 33, 0, 0, [4, 5, 7, 10]]
[9, 1, 10, 2, 7, '得点獲得', 29, 33, 0, 0, [4, 5, 7]]
[10, 2, 7, 1, 5, '得点獲得', 29, 40, 0, 0, [4, 5]]
ゲーム終了: 勝利プレイヤー : 2, 勝因 : スコア40





これで1万回やる場合は
こんな感じのコードで
データフレームにできます。
game_result, game_los = [], []
for i in range(10000):
  message, log_line = simulate_game()
  game_result.append([i+1] + message)
  for log in log_line:
    game_los.append([i+1] + log)

import pandas as pd
cols = ["ゲーム数", "勝利プレイヤー", "勝因","ターン数","獲得点数","P1得点", "P2得点", "P1電気回数", "P2電気回数", "残椅子個数"]
game_result_df = pd.DataFrame(game_result, columns=cols)
game_log_df = pd.DataFrame(game_los, columns=["ゲーム数"] + headers)

game_result_df.head()


シミュレーションの結果は
ぜひ動画を見てみてくださいませ


今回は電気椅子ゲームのシミュレーションコードについてでした
それでは!!

今回は映画サマーウォーズの
なつき先輩の誕生日から曜日求める
小ネタです


解説動画はこちら




モジュロ演算

今回はあの映画
「サマーウォーズ」に出てきた
なつき先輩の誕生日から
曜日を求めていたシーンのやつです

あのシーンでは、いわゆる
余り(剰余)を求める計算を行っていました

モジュロ演算とは
単に余りを求める計算のことです。

これとツェラーの公式
がつながります。


ツェラーの公式

西暦(YYYYMMDD)から
曜日を求める公式です。
h=(d+[26(m+1)/10]+Y+[Y/4]-2[y/100]+[y/400])mod 7

こんな感じの計算式ですが

これを使うと余りが
0-6の範囲になり、これが曜日に対応する
ということになります。

0=土曜日
1=日曜日
2=月曜日
3=火曜日
4=水曜日
5=木曜日
6=金曜日


誕生日から曜日を求めるコード


コードはこんな感じです
年月日の変数を変えて貰えば
どの年月日にも対応されます
# モジュロ演算で曜日を求めるコード
def zeller(year, month, day):
    # 1月と2月は前年の13月、14月として扱う
    if month < 3:
        month += 12
        year -= 1

    y = year % 100
    century = year // 100
    
    # ツェラーの公式より
    h = (day + (26 * (month + 1)) // 10 + y + (y // 4) - (2 * century) + (century // 4)) % 7
    
    # 曜日
    # 0=土曜日, 1=日曜日, 2=月曜日, 3=火曜日, 4=水曜日, 5=木曜日, 6=金曜日
    return h

weekdays = ["土曜日", "日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日"]

# 使用例
year, month, day = 2025, 2, 1

h = zeller(year, month, day)
weekday_name = weekdays[h]
print(f"{year}年{month}月{day}日は{weekday_name}です。")
2025年2月1日は土曜日です。


なつき先輩の誕生日の曜日はなんだったか?

なつき先輩の誕生日は
1992年7月19日
となっています

コードを使って求めると

日曜日

だそうです。

当たっていましたかね?



余り使う機会のない
小ネタですが
どこかで使われることがあったら
嬉しいかもしれないですね

今日はここまでです

それでは
 

今回はベンフォードの法則と
カイ二乗検定による不正の検出方法についてです。

解説動画はこちら



ベンフォードの法則

今回はベンフォードの法則のお話です

この法則は、自然界に出てくる多くの数値の最初の桁の分布が
「一様ではなくある特定の分布になっている」という法則のことです。

電気料金の請求書、住所の番地、株価、人口の数値、死亡率、川の長さなど...
特定の範囲に限定されたものは当てはまらないですが
これに当てはまるデータが世の中にはたくさんあります。

この法則によると
大きな数値ほど最初の桁に現れる確率は小さくなり
ベンフォードの法則に従った最初の桁の理論的確率は
Pythonコードでは以下の式で表せます

math.log10((d+1)/d)



ここから先のコードをGoogle Colabで試す場合は
以下をインストールしてください
pip install japanize_matplotlib


import math
import numpy as np
import scipy.stats as stats
import japanize_matplotlib
import matplotlib.pyplot as plt

# ベンフォードの理論的な確率分布
def benford_distribution():
    return [math.log10(1 + 1 / d) for d in range(1, 10)]

benford_probs = benford_distribution()

# 棒グラフの描画
labels = [1, 2, 3, 4, 5, 6, 7, 8, 9]
bar_width = 0.35
x = np.arange(len(labels))
plt.bar(x - bar_width/2, benford_probs, width=bar_width, label='理論的確率', color='blue')
plt.xlabel('最初の桁')
plt.ylabel('確率')
plt.title('ベンフォードの法則の比較')
plt.xticks(x, labels)
plt.legend()
plt.tight_layout()
plt.show()
download


ベンフォードの法則は何に使えるのか

次のような不正の調査などで利用されているらしいです
会計データ
選挙データ
化学データ
経済データ

ここから先はどのように不正を発見するのかを
見てみましょう。



ベンフォードの法則に従うかを調査する方法

・カイ二乗検定を用いる方法

カイ二乗検定は観察されたデータと期待される
データの間の差を評価するための統計的手法のことです

データの分布が特定の比率に従っているかどうかを
検定するために使用されます

検定統計量(カイ二乗統計量)を求め、カイ二乗分布を用いて
統計量と自由度からp値(確率)を求めます

P値が一定の水準以下の場合(確率が低い)
あり得ないことが起きているとし
観察された比率が期待される比率と異なると
結論づける事ができます。


カイ二乗検定を用いたベンフォードの法則比率との比較


ここでは理論値と
観測値として、操作した比率のデータで検証していきます。

以下のコードで描画させる事ができます。
import numpy as np
import scipy.stats as stats
import japanize_matplotlib
import matplotlib.pyplot as plt

# サンプルサイズを設定
n_samples = 1000  # 適宜変更

# ベンフォードの法則に従った最初の桁の理論的確率
benford_probs = [0.301, 0.176, 0.125, 0.097, 0.079, 0.067, 0.058, 0.051, 0.046]

# 観測された出現確率を設定
observed_probs = [0.295, 0.172, 0.166, 0.092, 0.074, 0.062, 0.053, 0.046, 0.040]

# 最初の桁のラベル
labels = [1, 2, 3, 4, 5, 6, 7, 8, 9]

# 棒グラフの幅
bar_width = 0.35
x = np.arange(len(labels))

# 棒グラフの描画
plt.bar(x - bar_width/2, benford_probs, width=bar_width, label='理論的確率', color='blue')
plt.bar(x + bar_width/2, observed_probs, width=bar_width, label='観測された確率', color='orange')

# グラフの設定
plt.xlabel('最初の桁')
plt.ylabel('確率')
plt.title('ベンフォードの法則の比較')
plt.xticks(x, labels)
plt.legend()
plt.tight_layout()
plt.show()
download-1

数字の3が出てくる確率を多くしました。
これが理論上の比率と合うのかを検定します。


# 出現数を計算
observed_counts = [int(p * n_samples) for p in observed_probs]
expected_counts = [int(p * n_samples) for p in benford_probs]

print("観測数 : ",observed_counts)
print("期待値 : ",expected_counts)

# カイ二乗検定を実施
chi2_stat, p_value = stats.chisquare(f_obs=observed_counts, f_exp=expected_counts)

# 結果を表示
print("カイ二乗統計量:", chi2_stat)
print("p値:", p_value)

# 結論
alpha = 0.05  # 有意水準
if p_value < alpha:
    print("ベンフォードの法則に従わないと判断される")
else:
    print("ベンフォードの法則に従うと判断される")
観測数 :  [295, 172, 166, 92, 74, 62, 53, 46, 40]
期待値 :  [301, 176, 125, 97, 79, 67, 58, 51, 46]
カイ二乗統計量: 16.30967165997854
p値: 0.03815623468852384
ベンフォードの法則に従わないと判断される


この操作された比率の場合は
ベンフォードの法則に従わないと判断されるようです。




カイ二乗検定の仕組み



ここからはカイ二乗検定の仕組みを
説明していきます。


まずは前提として何を検定するのかを定めます。
通常は仮説を置く、ということをしています。

仮説の設定:
帰無仮説 : 観察された比率は期待される比率と等しい
対立仮説 : 観察された比率は期待される比率と異なる

理論上の比率と観測された比率が正しいのか
異なるのかを仮説に置き
一般的には帰無仮説 には差がない(比率と等しい)とします。


1.カイ二乗値を求める

仮説を置いたら
まず初めにカイ二乗値を求めていきます。

カイ二乗値は下記の式で求められます。

(観測結果 - 理論値)**2 / 理論値 の合計値

観測結果と理論値の差が大きいと
大きくなる値になります。


2.自由度を求める

次に自由度ですが、この場合は
カテゴリ数 - 1
という値になります。

この場合、1-9までの数値の比率の個数だとすると
9-1 = 8になります。


3.カイ二乗の検定統計量と比べて大きいか小さいかを見る

自由度と有意水準から
カイ二乗分布を用いて、カイ二乗検定統計量の
閾値を求めていきます。

この閾値と先ほど求めたカイ二乗値を比較します。
その結果

閾値より小さい : よく有ること
閾値より大きい : よく有ることではない

となり、閾値より大きくなった場合は
P値(確率)が低くなり

あり得ない事が起きているとして帰無仮説を棄却し
対立仮説 : 観察された比率は期待される比率と異なる
を採択することになります。


カイ二乗分布の検定統計量の表を作ってみましょう
import numpy as np
import pandas as pd
from scipy.stats import chi2

# 有意水準
alpha_levels = [0.99, 0.975, 0.95, 0.9, 0.1, 0.05, 0.025, 0.01]
# 自由度
degrees_of_freedom = range(1, 10)

# カイ二乗分布の臨界値を計算
chi_square_table = {'自由度/有意水準': degrees_of_freedom}

for alpha in alpha_levels:
    chi_square_table[f'α = {alpha}'] = [chi2.ppf(1 - alpha, df) for df in degrees_of_freedom]

# カイ二乗値のDataFrameを作成
chi_square_df = pd.DataFrame(chi_square_table)
chi_square_df
スクリーンショット 2025-01-11 17.37.20

こんな感じで、有意水準と自由度で
閾値を求められます。

紙で行う場合は、この表を見て行いますが
Pythonプログラミングでは、これを使わず
直接確率を求められます。

import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import chi2

# 自由度
df = 8
# 有意水準
alpha = 0.05
# カイ二乗の臨界値を計算
critical_value = chi2.ppf(1 - alpha, df)

# 観測したカイ二乗値
observed_chi_squared = sum([(o*1000-b*1000)**2/(b*1000) for b,o in zip(benford_probs,observed_probs)])

# xの範囲を設定
x = np.linspace(0, 30, 1000)
# カイ二乗分布の確率密度関数を計算
y = chi2.pdf(x, df)

# グラフを描画
plt.figure(figsize=(10, 6))
plt.plot(x, y, label=f'Chi-squared Distribution (df={df})', color='blue')
plt.fill_between(x, y, where=(x >= critical_value), color='red', alpha=0.5, label='Rejection Region (p < 0.05)')
plt.axvline(critical_value, color='red', linestyle='--', label=f'Critical Value = {critical_value:.2f}')
plt.axvline(observed_chi_squared, color='green', linestyle='--', label=f'Observed Chi-squared = {observed_chi_squared:.2f}')
plt.title('Chi-squared Distribution with 5% Significance Level')
plt.xlabel('Chi-squared Value')
plt.ylabel('Probability Density')
plt.legend()
plt.grid()
plt.show()
download-2


この場合は求めたカイ二乗値は
閾値を超える値になるため
P値は低くなり、対立仮説の採択、となります。




実際のデータで比較するとどうなるか?

統計データを読み込みしてやってみましょう
e-statのデータを読み込みしてみます
(令和2年 都道府県・市区町村別の主な結果)

データのリンク先

こちらをColabで行う場合は
ファイル置き場においてください。


データを読み込むコードはこれです。
import pandas as pd
import numpy as np

file_path = "major_results_2020.xlsx"
  # 8行目をヘッダーとして指定(0-indexed)
df = pd.read_excel(file_path, header=8)

df.iloc[0:2, 35:]

これを集計して最初の数値のカウントと
比率を求めます。
data = df.iloc[:, 35:]

# 先頭の数字をカウントするための関数
def count_leading_digits(series):
    leading_digits = series.astype(str).str[0]
    return leading_digits.value_counts()

# 全列のデータを結合して先頭の数字をカウント
all_leading_digits = data.astype(str).stack().str[0]
digit_counts = all_leading_digits.value_counts().sort_index()
digit_counts = digit_counts[~digit_counts.index.str.contains('-')]

# 結果を表示
print(digit_counts)

# 比率を計算
total_count = digit_counts.sum()
leading_digit_ratios = digit_counts / total_count
print(leading_digit_ratios)
1    8324
2    4726
3    3465
4    2592
5    2240
6    1891
7    1594
8    1302
9    1330
Name: count, dtype: int64
1    0.303088
2    0.172080
3    0.126165
4    0.094378
5    0.081561
6    0.068854
7    0.058040
8    0.047408
9    0.048427
Name: count, dtype: float64


import numpy as np
import math
import matplotlib.pyplot as plt
from scipy.stats import norm
from collections import Counter

# ベンフォードの法則による理論的な確率分布
def benford_distribution():
    return [math.log10(1 + 1 / d) for d in range(1, 10)]

# ベンフォードの理論的分布
benford_dist = benford_distribution()

# サンプルデータの分布
sample_distribution = list(leading_digit_ratios.values)

# グラフ描画
x = range(1, 10)
fig, ax = plt.subplots(figsize=(10, 6))
ax.bar(x, sample_distribution, width=0.4, label="Sample Distribution", align="center", alpha=0.7)
ax.bar([xi + 0.4 for xi in x], benford_dist, width=0.4, label="Benford Distribution", color="orange", alpha=0.7)

# グラフの設定
ax.set_xticks(x)
ax.set_xticklabels([str(d) for d in x])
ax.set_xlabel("Leading Digit")
ax.set_ylabel("Frequency")
ax.set_title("Comparison of Sample Distribution and Benford's Law")
ax.legend()
plt.show()
download-3


カイ二乗検定を行うと
# カイ二乗検定の実施
import scipy.stats as stats

# サンプルの頻度を期待値に基づいて計算
expected_frequencies = [sum(sample_distribution) * p for p in benford_dist]
observed_frequencies = sample_distribution

# カイ二乗検定を実行
chi2_stat, p_value = stats.chisquare(observed_frequencies, f_exp=expected_frequencies)

# 結果を表示
print("カイ二乗統計量:", chi2_stat)
print("p値:", p_value)

# 有意水準を設定
alpha = 0.05
if p_value < alpha:
    print("帰無仮説を棄却します。サンプルデータはベンフォードの法則に従っていない可能性があります。")
else:
    print("帰無仮説を棄却できません。サンプルデータはベンフォードの法則に従っている可能性があります。")
カイ二乗統計量: 0.000739463432140538
p値: 0.9999999999999992
帰無仮説を棄却できません。
サンプルデータはベンフォードの法則に従っている可能性があります。


このデータの場合は
ベンフォードの法則に従っていないとは言えないようですね

正しいデータである可能性が高いです。



まとめ

ベンフォードの法則を用いると無作為に抽出したデータの場合
操作された数値を発見できる可能性はあります。

ただし、必ずしも当てはまる訳ではないので
見極めは重要です。

もしお手持ちに
会計データなどがある場合は
これで比率が正しいかを試してみると
面白いかもしれませんね。

今日はこれまでです
それでは



このページのトップヘ