重啟撲克機器人之路 -8:當效率反而成為陷阱

更新於 發佈於 閱讀時間約 18 分鐘
raw-image

在研究如何將PioSOLVER的解決方案整合進bot系統時,遇到了一些有趣的問題。原本打算使用checkmathpoker.com的API來存取preflop和postflop的解決方案,但每月154元的費用加上10,000次的請求限制,對於初期開發來說實在太過昂貴。

轉而使用PioSOLVER 2來產生heads-up preflop的解決方案,但馬上就遇到了如何將結果轉換成lookup table的問題。跟OpenHoldem時期相比,我不想再走回老路 - 手動複製貼上然後轉換格式,那實在太耗時間了。原本想透過PioSOLVER的UPI介面來處理,卻發現每次都需要重新載入18GB的解決方案,這在實際運行時根本不可行。

經過一番思考,我決定採取折衷的方案:先用JSON格式來儲存從PioSOLVER複製出來的preflop圖表。這樣做雖然還是需要一些手動工作,但至少在lookup速度上會快得多。目前專注在heads-up的情況,工作量應該還算可以接受,因為training app的選項有限,不需要處理太多變化。

不過當我開始考慮未來要擴展到6-max時,問題就變得複雜了。光是要處理不同的開牌大小(比如min open或3BB、10BB的3-bet)就會讓遊戲樹呈指數成長。這讓我想起在OpenHoldem專案中的教訓 - 試圖硬編碼所有能想到的情況,最後還是敵不過現實中無窮的變化,導致bot在未預期的情況下表現極差。

後來想到或許可以使用Machine learning model來學習solver output data,在經過幾番努力後,在加入了suited、pocket pair等特徵後,準確率更是提升到接近100%。特別是在處理不同stack size的情況時,模型展現出驚人的泛化能力,這讓我一度認為找到了一個突破口。

然而,就在準備深入開發這個方向時,突然意識到自己又不知不覺地走上了老路 - 那條以solver為基礎的道路。回想起幾年前的經驗,無論是用OpenHoldem硬編碼solver策略,還是透過其他方式實作,最終的結果都是相似的 - 勉強打平rake的mediocre表現。反觀那些根據玩家類型進行調整的exploitative策略,雖然看似不夠"完美",卻能帶來更好的收益。

這個發現讓我陷入了深思。為什麼明明是"理論正確"的solver策略,實戰效果卻總是不如預期?更弔詭的是,我從未聽說過有人完全依照solver策略來打而獲得巨大成功。那些成功的職業玩家,往往是從solver中學習,而不是盲目跟從。這讓我意識到,也許solver策略對我來說就像是一個美麗的陷阱 - 因為它"完美"且容易驗證,反而讓我一再地落入這個看似安全的選擇。

最終,我決定暫停當前的開發方向,轉而思考如何善用手上的200萬手spin&go歷史記錄。雖然還不確定具體該如何處理這些數據,但我相信這可能是一個更有價值的方向。這個決定讓我感到些許遺憾 - 畢竟當前的模型開發已經有了不錯的進展。但有時候,放下已經投入的工作,承認自己又走錯路,可能比固執地堅持更需要勇氣。

這次的經驗再次提醒我,在poker的世界裡,最重要的或許不是追求完美的理論策略,而是如何有效地針對不同對手進行調整。即便這條路看起來沒有solver策略那麼清晰明確,但可能才是真正值得投入的方向。

我簡單測試Machine Learning Model在不同stack sizes data的程式碼:

import pandas as pd

import numpy as np



from sklearn.ensemble import RandomForestRegressor

from sklearn.model_selection import KFold

from sklearn.metrics import mean_squared_error



# -------------------------

# 1. Load & Merge Data for Multiple Stack Sizes

# -------------------------

df_80 = pd.read_csv("hu_80bb_r_0.csv")

df_100 = pd.read_csv("hu_100bb_r_0.csv")

df_120 = pd.read_csv("hu_120bb_r_0.csv")



df_80["stack_size"] = 80

df_100["stack_size"] = 100

df_120["stack_size"] = 120



df = pd.concat([df_80, df_100, df_120], ignore_index=True)



# -------------------------

# 2. Clean Frequencies

# -------------------------

# We'll define a small function that sets frequencies near 00, near 100100.

def fix_freq(freq, eps=1.0):

"""

If freq >= 100 - eps, set it to 100.

If freq <= eps, set it to 0.

Otherwise, leave it as is.

"""

if freq >= 100 - eps:

return 100.0

elif freq <= eps:

return 0.0

else:

return freq



df["RAISE 25"] = df["RAISE 25"].apply(fix_freq)

df["FOLD"] = df["FOLD"].apply(fix_freq)



# -------------------------

# 3. Parse & Canonicalize Hole Cards

# -------------------------

rank_map = {'2':2, '3':3, '4':4, '5':5, '6':6,

'7':7, '8':8, '9':9, 'T':10,

'J':11, 'Q':12, 'K':13, 'A':14}

suit_map = {'c':1, 'd':2, 'h':3, 's':4}



def parse_hand_to_canonical(hand_str):

"""

hand_str like 'Qd2s' or '2sQd' (4 chars total).

1) Extract card1, card2

2) Convert each to (rank, suit)

3) Canonicalize: Ensure (rank1, suit1) >= (rank2, suit2)

by rank primarily, then suit as tiebreaker

4) Return (rank1, suit1, rank2, suit2)

"""

card1 = hand_str[0:2] # e.g. 'Qd'

card2 = hand_str[2:4] # e.g. '2s'

# Parse ranks and suits

r1 = rank_map[card1[:-1]]

s1 = suit_map[card1[-1]]

r2 = rank_map[card2[:-1]]

s2 = suit_map[card2[-1]]

# If second card is "bigger" by rank or tie rank & bigger suit,

# swap so that (r1, s1) is always the "higher" or canonical card.

# This ensures Qd2s == 2sQd => same final representation.

if (r2 > r1) or (r2 == r1 and s2 > s1):

r1, r2 = r2, r1

s1, s2 = s2, s1

return r1, s1, r2, s2



# Apply to entire DataFrame

df[["rank1", "suit1", "rank2", "suit2"]] = df["Hand"].apply(

lambda h: pd.Series(parse_hand_to_canonical(h))

)



# -------------------------

# 4. Additional Indicators

# -------------------------

df["is_suited"] = (df["suit1"] == df["suit2"]).astype(int)

df["is_pair"] = (df["rank1"] == df["rank2"]).astype(int)



def is_connector(row):

return 1 if abs(row["rank1"] - row["rank2"]) == 1 else 0



def is_1_gap(row):

return 1 if abs(row["rank1"] - row["rank2"]) == 2 else 0



df["is_connector"] = df.apply(is_connector, axis=1)

df["is_1_gap"] = df.apply(is_1_gap, axis=1)



# -------------------------

# 5. Build the Target: Fold Frequency in [0,1]

# -------------------------

df["fold_freq"] = df["FOLD"] / 100.0 # convert from [0..100] to [0..1]



# -------------------------

# 6. Define X (Features) and y (Target)

# -------------------------

feature_cols = [

"rank1", "suit1", "rank2", "suit2",

"stack_size",

"is_suited", "is_pair", "is_connector", "is_1_gap"

]

X = df[feature_cols]

y = df["fold_freq"]



# -------------------------

# 7. K-Fold Cross-Validation

# -------------------------

kf = KFold(n_splits=5, shuffle=True, random_state=42)

model = RandomForestRegressor(n_estimators=100, random_state=42)



mse_list = []

for train_index, val_index in kf.split(X):

X_train, X_val = X.iloc[train_index], X.iloc[val_index]

y_train, y_val = y.iloc[train_index], y.iloc[val_index]



model.fit(X_train, y_train)

y_pred = model.predict(X_val)



mse = mean_squared_error(y_val, y_pred)

mse_list.append(mse)



mse_array = np.array(mse_list)

rmse_array = np.sqrt(mse_array)

print("MSE (per fold):", mse_array)

print("RMSE (per fold):", rmse_array)

print("Mean RMSE:", rmse_array.mean(), "Std dev:", rmse_array.std())



# -------------------------

# 8. Train Final Model on ALL Data

# -------------------------

final_model = RandomForestRegressor(n_estimators=100, random_state=42)

final_model.fit(X, y)



# -------------------------

# 9. Prepare Function to Query Any Hand + Stack

# -------------------------

def prepare_features(hand_str, stack_size):

"""

Convert a hand like 'AcAd' or '2sQd' + a stack size into

the 9-element feature vector. We do the same canonical parse

to ensure consistent ordering of the two cards.

Returns a 2D numpy array suitable for model.predict().

"""

card1 = hand_str[0:2] # e.g. '2s'

card2 = hand_str[2:4] # e.g. 'Qd'

# Parse & canonicalize

r1, s1, r2, s2 = parse_hand_to_canonical(hand_str)

# Indicators

is_suited = 1 if s1 == s2 else 0

is_pair = 1 if r1 == r2 else 0

is_connector = 1 if abs(r1 - r2) == 1 else 0

is_1_gap = 1 if abs(r1 - r2) == 2 else 0



features = [

r1, s1,

r2, s2,

stack_size,

is_suited,

is_pair,

is_connector,

is_1_gap

]

return np.array([features])



# -------------------------

# Example Testing

# -------------------------

test_hands = ["Qd2s", "2sQd", "AcAd", "5h9d", "9h5s"]

stack_size = 90



for hand in test_hands:

X_custom = prepare_features(hand, stack_size)

pred_fold = final_model.predict(X_custom)[0]

pred_raise = 1.0 - pred_fold

print(f"Hand: {hand}, Stack: {stack_size}")

print(f" Predicted fold frequency: {pred_fold:.4f} ({pred_fold*100:.2f}%)")

print(f" Predicted raise frequency: {pred_raise:.4f} ({pred_raise*100:.2f}%)")

print("----")

留言
avatar-img
留言分享你的想法!
avatar-img
傑劉的沙龍
3會員
18內容數
傑劉的沙龍的其他內容
2025/03/16
記錄了對撲克數據庫程式碼的深入理解,以及如何通過精確的查詢獲得準確的分析結果。通過重新組織action type的分類,讓後續的數據分析變得更加高效。這個數據庫將是撲克機器人專案的重要組成部分,用於建立更精確的對手模型。
Thumbnail
2025/03/16
記錄了對撲克數據庫程式碼的深入理解,以及如何通過精確的查詢獲得準確的分析結果。通過重新組織action type的分類,讓後續的數據分析變得更加高效。這個數據庫將是撲克機器人專案的重要組成部分,用於建立更精確的對手模型。
Thumbnail
2025/03/14
記錄了在建構撲克數據庫過程中遇到的挑戰和收穫。探討了自建系統與現成工具的差異,以及如何確保數據準確性。同時反思了精確表達查詢需求的重要性,以及自建系統潛在的長期價值。
Thumbnail
2025/03/14
記錄了在建構撲克數據庫過程中遇到的挑戰和收穫。探討了自建系統與現成工具的差異,以及如何確保數據準確性。同時反思了精確表達查詢需求的重要性,以及自建系統潛在的長期價值。
Thumbnail
2025/03/13
記錄了在撲克機器人開發中從機器學習模型轉向建立自定義數據庫的過程,以及這個策略轉變背後的思考。通過分析真實玩家的行動分布,希望能訓練出更有效的撲克機器人。
Thumbnail
2025/03/13
記錄了在撲克機器人開發中從機器學習模型轉向建立自定義數據庫的過程,以及這個策略轉變背後的思考。通過分析真實玩家的行動分布,希望能訓練出更有效的撲克機器人。
Thumbnail
看更多
你可能也想看
Thumbnail
大家好,我是一名眼科醫師,也是一位孩子的媽 身為眼科醫師的我,我知道視力發展對孩子來說有多關鍵。 每到開學季時,診間便充斥著許多憂心忡忡的家屬。近年來看診中,兒童提早近視、眼睛疲勞的案例明顯增加,除了3C使用過度,最常被忽略的,就是照明品質。 然而作為一位媽媽,孩子能在安全、舒適的環境
Thumbnail
大家好,我是一名眼科醫師,也是一位孩子的媽 身為眼科醫師的我,我知道視力發展對孩子來說有多關鍵。 每到開學季時,診間便充斥著許多憂心忡忡的家屬。近年來看診中,兒童提早近視、眼睛疲勞的案例明顯增加,除了3C使用過度,最常被忽略的,就是照明品質。 然而作為一位媽媽,孩子能在安全、舒適的環境
Thumbnail
提供一條簡單公式、一套盤點思路,幫助你快速算出去日本自助旅遊需要準備多少日幣現金!
Thumbnail
提供一條簡單公式、一套盤點思路,幫助你快速算出去日本自助旅遊需要準備多少日幣現金!
Thumbnail
記錄了在撲克機器人開發中從機器學習模型轉向建立自定義數據庫的過程,以及這個策略轉變背後的思考。通過分析真實玩家的行動分布,希望能訓練出更有效的撲克機器人。
Thumbnail
記錄了在撲克機器人開發中從機器學習模型轉向建立自定義數據庫的過程,以及這個策略轉變背後的思考。通過分析真實玩家的行動分布,希望能訓練出更有效的撲克機器人。
Thumbnail
記錄了在開發過程中與LLM合作的經驗教訓,以及在資料處理和模型設計上的一些思考。特別強調了在開發過程中,有時看似繁瑣的基礎工作反而是最重要的。
Thumbnail
記錄了在開發過程中與LLM合作的經驗教訓,以及在資料處理和模型設計上的一些思考。特別強調了在開發過程中,有時看似繁瑣的基礎工作反而是最重要的。
Thumbnail
記錄了在開發撲克機器人對手模型時,如何與語言模型協作的心得,以及在這過程中對開發方法論的一些思考。特別強調了「先求有,再求好」的重要性,以及如何在保持開發效率和深入理解技術細節之間找到平衡。
Thumbnail
記錄了在開發撲克機器人對手模型時,如何與語言模型協作的心得,以及在這過程中對開發方法論的一些思考。特別強調了「先求有,再求好」的重要性,以及如何在保持開發效率和深入理解技術細節之間找到平衡。
Thumbnail
記錄了在開發撲克機器人時處理數據的心得,從最初對XML格式感到困惑,到現在能夠從容面對數據處理的轉變過程。反思了數據品質在機器學習project中的重要性,以及自己在程式開發能力上的進步
Thumbnail
記錄了在開發撲克機器人時處理數據的心得,從最初對XML格式感到困惑,到現在能夠從容面對數據處理的轉變過程。反思了數據品質在機器學習project中的重要性,以及自己在程式開發能力上的進步
Thumbnail
記錄了在分析200萬筆撲克歷史記錄時的思考過程,從最初被 Q-Learning 吸引,到理解其在不完整資訊遊戲中的侷限,最終決定轉向建立對手模型系統的過程。反映了在技術選擇時,如何在炫酷與實用之間找到平衡。
Thumbnail
記錄了在分析200萬筆撲克歷史記錄時的思考過程,從最初被 Q-Learning 吸引,到理解其在不完整資訊遊戲中的侷限,最終決定轉向建立對手模型系統的過程。反映了在技術選擇時,如何在炫酷與實用之間找到平衡。
Thumbnail
記錄了在開發撲克機器人時,從對機器學習模型的成功驗證,到意識到自己又回到solver策略老路的過程。最終決定改變方向,轉向分析實戰數據的心路歷程。
Thumbnail
記錄了在開發撲克機器人時,從對機器學習模型的成功驗證,到意識到自己又回到solver策略老路的過程。最終決定改變方向,轉向分析實戰數據的心路歷程。
Thumbnail
記錄了放棄使用大型語言模型作為撲克機器人核心的決定過程,以及新的混合策略方案的構思。文章探討了技術選擇的考量因素,並回顧了過去開發經驗帶來的啟發。
Thumbnail
記錄了放棄使用大型語言模型作為撲克機器人核心的決定過程,以及新的混合策略方案的構思。文章探討了技術選擇的考量因素,並回顧了過去開發經驗帶來的啟發。
Thumbnail
記錄了在開發撲克牌AI時,從機器學習到大型語言模型的技術選擇過程,以及對各種可能解決方案的思考與權衡。
Thumbnail
記錄了在開發撲克牌AI時,從機器學習到大型語言模型的技術選擇過程,以及對各種可能解決方案的思考與權衡。
Thumbnail
記錄了在開發撲克牌辨識系統時遇到的按鈕辨識挑戰,以及從中學到的debug思維和版本控制重要性。文章分享了技術解決方案的演進過程,也反思了個人開發習慣需要改進的地方。
Thumbnail
記錄了在開發撲克牌辨識系統時遇到的按鈕辨識挑戰,以及從中學到的debug思維和版本控制重要性。文章分享了技術解決方案的演進過程,也反思了個人開發習慣需要改進的地方。
Thumbnail
記錄了在開發撲克機器人時,在選擇使用傳統的template matching方法還是新的機器學習技術間的掙扎,最終決定採用雙軌並行的開發策略。
Thumbnail
記錄了在開發撲克機器人時,在選擇使用傳統的template matching方法還是新的機器學習技術間的掙扎,最終決定採用雙軌並行的開發策略。
追蹤感興趣的內容從 Google News 追蹤更多 vocus 的最新精選內容追蹤 Google News