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

Python

今回は疑似乱数の生成方法についてです


解説動画はこちら




乱数と疑似乱数

今回は疑似乱数についてのお話です

一般的に乱数というものは
予測できない数値のことで
原子核の放射崩壊により放出された放射線のカウント
のように予測が出来ない数値のことです

一方で疑似乱数とは
コンピューターで生成した一定の規則に基づく
乱数のような値のことで

真の乱数列は本来、規則性も再現性もないものであるため
本来は確定的な計算によって求めることはできないです

擬似乱数は計算によって作るので
生成法と内部状態が判れば予測も可能ではあります。

また過去に現れた数と同じ数が現れた際のその長さを
周期と言っています。



疑似乱数の作成手法


一般的には初期値(シード)と
演算法(アルゴリズム)を用いて生成します。



平方採中法 (middle-square method)

1946年頃、数学者ノイマンによって提唱された手法です。

この方法は
適当に初期値を決める 例 : 1234
その数を 2 乗した値の中央にある必要な桁数を採って
次の乱数とする

これを繰り返して乱数列とする方法です。

Pythonでの実装例はこんな感じになります。

# ノイマンの平方採中法による乱数生成
def middle_square_method(seed, num_samples=10, num_digits=4):
    random_numbers = []
    current_value = seed
    for _ in range(num_samples):
        num_digits = len(str(current_value))
        # 2乗して中央の桁を取り出す
        squared_value = current_value ** 2
        squared_str = str(squared_value).zfill(2 * num_digits)
        # 中央の桁を取り出す
        start = len(squared_str) - num_digits - 2
        middle_digits = squared_str[start: start + 4]
        current_value = int(middle_digits)
        random_numbers.append(current_value)
    return random_numbers

# 初期値とパラメータ設定
seed = 1234  # 初期値(任意の4桁の数)
random_nums = middle_square_method(seed)
print(random_nums)



線形合同法 (linear congruential method)

次のような漸化式による生成方法です。

スクリーンショット 2025-04-05 17.35.45

周期の最大は m で
パラメータ次第では周期が短くなり
予想ができてしまう手法です。


class LCG:
    def __init__(self, seed=1, a=1664525, c=1013904223, m=2**32):
        self.a = a
        self.c = c
        self.m = m
        self.state = seed

    def next(self):
        self.state = (self.a * self.state + self.c) % self.m
        return self.state / self.m

# 使用例
seed = 1320
lcg = LCG(seed=seed)
for _ in range(10):
    print(lcg.next())
0.7476371766533703
0.0075369239784777164



Xorshift

George Marsagliaが2003年に提案した手法です

ビット演算を用いた高速な擬似乱数生成法で
基本的な計算ステップは次の3つです。
state ^= (state << a)
state ^= (state >> b)
state ^= (state << c)
a, b, c は定数(一般的には a=13, b=17, c=5)
^= はビット単位の XOR を行って
結果を state に代入です。
class XorShift32:
    def __init__(self, seed=2463534242):
        self.state = seed

    def next(self):
        x = self.state
        x ^= (x << 13) & 0xFFFFFFFF
        x ^= (x >> 17)
        x ^= (x << 5) & 0xFFFFFFFF
        self.state = x & 0xFFFFFFFF
        return self.state / 0xFFFFFFFF

# 使用例
xorshift = XorShift32(seed=123456789)
for _ in range(5):
    print(xorshift.next())
0.6321277193799912
0.5212643641329521


メルセンヌ・ツイスタ (Mersenne Twister : MT)

1996年に松本眞と西村拓士によって
国際会議で発表された手法です。

名前の由来は
周期がメルセンヌ素数(2^19937 - 1)であることからだそうです

•出力:32ビットの整数
•長所:
•非常に長い周期(2^19937 - 1)
•高次元(623次元)でも均一な分布を持つ
•高速(特にビット演算が中心)

以下の3ステップで計算されています。
1.初期化(シードによる状態配列の構築)
2.状態配列の更新(Twist)
3.出力整形(Tempering)


元々Pythonのrandomなどの実装は
この手法を基にしているそうなので
実装する必要は無いのですが
中身の実装を真似るとこんな感じのコードになります
class MT19937:
    # 初期化(シードによる状態配列の構築)
    def __init__(self, seed):
        self.w, self.n, self.m, self.r = 32, 624, 397, 31
        self.a = 0x9908B0DF
        self.u, self.d = 11, 0xFFFFFFFF
        self.s, self.b = 7, 0x9D2C5680
        self.t, self.c = 15, 0xEFC60000
        self.l = 18
        self.f = 1812433253

        self.MT = [0] * self.n
        self.index = self.n
        self.lower_mask = (1 << self.r) - 1
        self.upper_mask = (~self.lower_mask) & 0xFFFFFFFF

        self.MT[0] = seed
        for i in range(1, self.n):
            self.MT[i] = (self.f * (self.MT[i - 1] ^ (self.MT[i - 1] >> (self.w - 2))) + i) & 0xFFFFFFFF

    # 状態配列の更新(Twist)
    # 複数のビットを結合して新しい状態を作り出す処理
    def twist(self):
        for i in range(self.n):
            x = (self.MT[i] & self.upper_mask) + (self.MT[(i + 1) % self.n] & self.lower_mask)
            xA = x >> 1
            if x % 2 != 0:
                xA ^= self.a
            self.MT[i] = self.MT[(i + self.m) % self.n] ^ xA
        self.index = 0

    # 出力整形(Tempering)
    # 出力する前にビットをシャッフルして統計的性質を良くする
    def extract_number(self):
        if self.index >= self.n:
            self.twist()

        y = self.MT[self.index]
        y ^= (y >> self.u) & self.d
        y ^= (y << self.s) & self.b
        y ^= (y << self.t) & self.c
        y ^= (y >> self.l)
        self.index += 1
        return y & 0xFFFFFFFF

    def random(self):
        return self.extract_number() / 0xFFFFFFFF


まとめ

コンピューターでは真の乱数を生成することは
非常に困難ですが乱数に近い値を計算で求める事は出来ます

しかし乱数生成アルゴリズムが分かってしまった場合には
リスクが発生することがあります。


システム内で使用される乱数
(セッションID、認証トークン、パスワードのハッシュなど)が
予測されて攻撃されたり

暗号化や認証に使用される乱数
(例:暗号鍵生成やトークンの生成)が
予測可能だと、暗号が破られやすくなるでしょう。

またゲームやギャンブルサイトでの
不正行為にも使われてしまった例があったようです。

まあ、疑似乱数を1から生成するコードを実装する機会は
ほとんど無いかもしれませんが、アルゴリズムの採択によっては
それがシステム上のリスクになりえる事は
考えておいても良いんじゃ無いでしょうか

今回は疑似乱数についてのお話でした。
それでは。


今回は直感に反する確率の逆説の問題集です


解説動画はこちら



直感に反する確率の問題

なんとなくコレかなと思ったけど
答えはそれと違っていたというような
直感に反する答えが出そうな問題です。


問題とともに
回答に使用したコードを載せておきます。



1.確率の逆説


第1問

サイコロを2回振るとき 少なくとも1回は1が出る確率は
1. 3分の1より小さい
2. ちょうど3分の1(33.3%)
3. 3分の1より大きい

# サイコロを2回振るとき 少なくとも1回は1が出る確率
import random

num = 1000000
count = 0
for _ in range(num):
    dices = [random.choice([1,2,3,4,5,6]),random.choice([1,2,3,4,5,6])]
    if 1 in dices:
        count+=1

print(f"少なくとも1が1回はでた回数 : {count}")
print(f"{count * 100 / num}%")



第2問

学校のクラス30人の生徒の中に誕生日が同じ人がいる確率は
1. 約30%
2. 約50%
3. 約70%
4. 約90%


# n人の生徒の中に誕生日が同じ人がいる確率
import random
from collections import Counter

birthdays = list(range(365))
num = 30
cnt, all_cnt = 0,0
for i in range(1000000):
    all_cnt+=1
    tmp = random.choices(birthdays,k=num)
    c = Counter(tmp)
    mx = c.most_common(1)
    if mx[0][1]>1:
        cnt+=1
print(f"誕生日が同じ人がいた回数 : {cnt}")
print(f"誕生日が同じ人がいる確率 : {cnt * 100 /all_cnt}%")




2.ギャンブラーの誤謬

過去の結果が現在の結果に影響すると誤って考える心理


第3問

コインを4回投げてすべて表が出た場合、次の投げで裏が出る確率は
1. 50%より小さい
2. ちょうど50%
3. 50%より大きい


# コインを投げて4回表が出た後の出目の確率
import random

def simulate_coin_tosses(trials):
    result = {'表':0,'裏':0}
    coins = [random.choice(['表', '裏']) for _ in range(trials)]
    for i in range(0, trials-5):
        if coins[i:i+4] == ['表', '表', '表', '表']:
            next = coins[i+4]
            result[next]+=1
    return result['表'], result['裏']

# 試行回数
k = 1000000
total_heads, total_tails = simulate_coin_tosses(k)

print(f"試行回数: {k}")
print(f"表の合計回数: {total_heads}")
print(f"裏の合計回数: {total_tails}")
print(f"4回表が出た後の裏が出る確率は : {total_tails * 100 / (total_heads + total_tails):.6}%")




第4問

コインを1000回投げた時に、連続して表が出る最高回数は
1. 8回より少ない
2. 8-10回くらい
3. 10回より多い


# コインをnum回投げて、連続して表が出る最高回数
import random

def max_consecutive_heads(trials):
    coins = [random.choice(['表', '裏']) for _ in range(trials)]
    #print(coins)
    #print(f"表の数 : {coins.count('表')}")
    max_count, current_count = 0, 0
    for coin in coins:
        if coin == '表':
            current_count += 1
            max_count = max(max_count, current_count)
        else:
            current_count = 0
    return max_count

# 試行回数
num = 1000
max_heads = [max_consecutive_heads(num) for _ in range(1000)]
mean_heads = sum(max_heads) / len(max_heads)
print(f"試行回数: {num}")
print(f"連続して出現した表の最高回数: {mean_heads}")



答えは
シミュレーションプログラムを実行するか
動画をご覧ください。




そんなに確率高くないだろうと思っていたら
意外と高い確率だったり
意外と低かったり

そんなこともあるんじゃないかと思います。

プログラムを使えば
色々シミュレーションして
おおよその確率を求めることができるので

さまざまな事象の確率を求めたいなら
プログラミングを習得するのが
おすすめです。


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

 

今回はZIPとかPDFのパスワードを
総当たりで開けに行くやつのご紹介です

解説動画はこちら




ファイルにパスワード掛かっていて開けられないよーーー
どうにかならない?

そんな時ありますよね!!!!

そんな時はプログラムで突破すればいい

今回は
ZIPやPDFのパスワードを破るプログラム
についての解説です。


 
ライブラリのインストール

Google Colab上で
ZIPやPDFを扱うのに必要な
ライブラリのインストールです。
pip install pyzipper
pip install pypdf
pip install reportlab



パスワード付きファイルの用意

パスワード付きのZIP , PDFファイルを
作成しておきます。

今回のパスワードは
a12
として
PDFファイルや
ZIP用のファイルを先に作っておきます。

lock_password = "a12"

from reportlab.pdfgen import canvas
from PIL import Image

# PDFを作成
pdf_filename = "original.pdf"
c = canvas.Canvas(pdf_filename)
c.drawString(100, 750, "Hello, this is a sample PDF file.")
c.drawString(100, 730, "This PDF was created using Python and reportlab.")
c.save()

# ZIP用の内包ファイルを用意
with open("secret.txt","w") as _w:
    _w.write("This is a secret message")

Image.new("RGB", (100, 100), (255, 255, 255)).save("image.png")


パスワード付きのZIPファイルを作るコード
import pyzipper

# パスワード付きzipファイルを作成する
def create_protected_zip(file_list, zip_name, password):
    with pyzipper.AESZipFile(zip_name, 'w', compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES) as zf:
        zf.setpassword(password.encode("utf-8"))
        for file in file_list:
            zf.write(file)
    print(f"[+] パスワード付きZIPを作成: {zip_name}")

create_protected_zip(["secret.txt", "image.png"], "protected.zip", lock_password)


PDFにパスワードをかけるコード
from pypdf import PdfReader, PdfWriter

# パスワード付きpdfファイルを作成する
def create_protected_pdf(input_pdf, output_pdf, password):
    reader = PdfReader(input_pdf)
    writer = PdfWriter()
    
    for page in reader.pages:
        writer.add_page(page)

    writer.encrypt(password)  # パスワードを設定
    with open(output_pdf, "wb") as f:
        writer.write(f)
    print(f"[+] パスワード付きPDFを作成: {output_pdf}")

create_protected_pdf("original.pdf", "protected.pdf", lock_password)




ZIPとPDFファイルのパスワードを破るコード


ZIPとPDFに対応
総当たりでパスワードを当てにいくものです
一応辞書にも対応しています。
import itertools
import string
import zipfile
import pyzipper
from pypdf import PdfReader

def extract_zip(file_path, password):
    """ ZIPファイルの解凍 (ZIPCrypto & AES-256対応) """
    password_bytes = password.encode("utf-8")  # バイト列に変換
    try:
        with zipfile.ZipFile(file_path) as zf:
            zf.extractall(pwd=password_bytes)
        print(f"[+] ZIP のパスワード発見: {password}")
        return True
    except (RuntimeError, NotImplementedError):
        pass

    try:
        with pyzipper.AESZipFile(file_path) as zf:
            zf.setpassword(password_bytes)
            zf.extractall()
        print(f"[+] PDF のパスワード発見: {password}")
        return True
    except Exception as e:
        return False

def try_password(file_path, file_type, password):
    """ ZIP または PDF のパスワードを試す """
    try:
        if file_type == "zip":
            return extract_zip(file_path, password)
        elif file_type == "pdf":
            reader = PdfReader(file_path)
            if reader.decrypt(password) == 0:
                raise ValueError("Incorrect password")
            print(f"[+] PDF のパスワード発見: {password}")
            return True
    except Exception:
        return False

def password_cracker(file_path, mode="brute", password_list=None, max_length=4):
    """ ZIP/PDFファイルのパスワードを解析 """
    if file_path.endswith(".zip"):
        file_type = "zip"
    elif file_path.endswith(".pdf"):
        file_type = "pdf"
    else:
        print("[-] サポートされていないファイル形式です")
        return

    print(f"[*] {file_path} のパスワード解析開始 (モード: {mode})")

    count = 0

    # **辞書攻撃**
    if mode == "dictionary" and password_list:
        with open(password_list, "r", encoding="utf-8") as file:
            for password in file:
                count += 1
                if try_password(file_path, file_type, password.strip()):
                    print(f"[+] 試行回数: {count}")
                    return

    # **総当たり攻撃**
    elif mode == "brute":
        characters = string.ascii_lowercase + string.digits  # "abcdefghijklmnopqrstuvwxyz0123456789"
        for length in range(1, max_length + 1):
            for password in itertools.product(characters, repeat=length):
                count += 1
                if try_password(file_path, file_type, "".join(password)):
                    print(f"[+] 総当たり攻撃試行回数: {count}")
                    return

    print("[-] パスワードが見つかりませんでした")

使い方は
# 実行例(ZIP)
password_cracker("protected.zip", mode="brute", max_length=3)
[*] protected.zip のパスワード解析開始 (モード: brute)
[+] PDF のパスワード発見: a12
[+] 総当たり攻撃試行回数: 2333

こんな感じで、開けることができます。

max_length
を増やせば、大きな桁数のパスワードにも対応

文字種を増やしたい場合は
コード上の変数 characters
これに文字列を追加してください。




おまけ


文字種と文字数による
パスワードの強度についてです。

今回は文字種の数が 36種類
パスワード文字数が3桁でした

文字種36個の時の
パスワード桁数による最大試行回数は
以下のようになります。


パスワードの長さによる最大試行回数 : 文字数 01 :  0000000000000036
パスワードの長さによる最大試行回数 : 文字数 02 :  0000000000001296
パスワードの長さによる最大試行回数 : 文字数 03 :  0000000000046656
パスワードの長さによる最大試行回数 : 文字数 04 :  0000000001679616
パスワードの長さによる最大試行回数 : 文字数 05 :  0000000060466176
パスワードの長さによる最大試行回数 : 文字数 06 :  0000002176782336
パスワードの長さによる最大試行回数 : 文字数 07 :  0000078364164096
パスワードの長さによる最大試行回数 : 文字数 08 :  0002821109907456
パスワードの長さによる最大試行回数 : 文字数 09 :  0101559956668416
パスワードの長さによる最大試行回数 : 文字数 10 :  3656158440062976



一般的なPCであれば7-8桁くらいまでなら
開けられそうですね


これ以上となると
少し工夫が必要になってきます。

まあ、会社で使うパスワードは
6桁くらいまでに抑えておいた方が
いざというとき開けられなくなるんで
いいかもしれません


今回はパスワードが開けられなくて困った際の
パスワード開けプログラムについてでした

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

今回は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

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

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

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

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

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

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

このページのトップヘ