সরাসরি পর্বে যান
পর্ব ৪২ · ৭৯-এর মধ্যে · মডিউল ৭

খেলার search — minimax আর alpha-beta

Adversarial Search — Game Trees, Minimax, Alpha-Beta Pruning, Evaluation Functions and Monte Carlo Tree Search

Taught in Bangla, this lesson defines games as search problems, solves tic-tac-toe completely with memoized minimax, traces alpha-beta pruning by hand and measures how move ordering changes the work, then covers evaluation functions, the horizon effect, expectiminimax, Nim as a known-answer check and Monte Carlo tree search with UCB1.

এই পর্বে যা শিখবেন

  • কোন খেলা এই পর্বের পদ্ধতির উপযোগী, আর একটি খেলাকে ছয়টি অংশে লেখা
  • Game tree-র আকার: tic-tac-toe-এর আসল গণনা, দাবার মোটা অনুমান
  • Minimax হাতে আর Python-এ; tic-tac-toe পুরো সমাধান
  • Alpha-beta-র হাতে trace আর চালের ক্রমের প্রভাব, চালিয়ে মাপা
  • Evaluation function, horizon effect, Nim, expectiminimax, MCTS আর সংযত ইতিহাস

আগের তিন পর্বে (পর্ব ৩৯, ৪০ আর ৪১) search-এর জগৎ ছিল শান্ত: রিকশাওয়ালা মিরপুর থেকে মতিঝিলের রাস্তা খোঁজেন, আর রাস্তাগুলো তাঁর বিরুদ্ধে ষড়যন্ত্র করে না। এই পর্বে জগতে আরেকজন agent আসছে, যে চায় আপনি হারুন। এটিই adversarial search: চায়ের দোকানে দাবার বোর্ডের দুই পাশের হিসাব, কম্পিউটারের ভাষায়। Graph আর DFS-এর data-structure দিক DSA কোর্সের DFS আর recursion পর্বে; এখানে দেখব খেলাকে কীভাবে search সমস্যা হিসেবে লেখা হয়, আর কম খরচে নিখুঁত চাল কীভাবে বের হয়।

১ · প্রতিপক্ষসহ search — কোন খেলা, আর খেলার সংজ্ঞা

সব খেলা এক রকম নয়। এই পর্বের মূল পদ্ধতিগুলো (minimax, alpha-beta) সবচেয়ে পরিষ্কারভাবে কাজ করে এমন খেলায়, যার চারটি বৈশিষ্ট্য আছে:

  • দুই খেলোয়াড়, পালা করে চাল: নাম দেব MAX (যে মান বাড়াতে চায়) আর MIN (যে কমাতে চায়)।
  • Zero-sum: একজনের জয় মানে অন্যজনের হার (জয় +1, হার −1, ড্র 0); তাই MAX-এর মান যত কম, MIN তত খুশি।
  • Perfect information: সব দেখা যায়, কিছু লুকানো নেই; পর্ব ১-এর ভাষায় fully observable.
  • ভাগ্যের ভূমিকা নেই: ছক্কা বা তাস বাটা নেই, মানে deterministic.
খেলাভাগ্য আছে?লুকানো তথ্য?এই পর্বের কোন পদ্ধতি
Tic-tac-toe, দাবা, Go, checkers, Nimনানাminimax, alpha-beta, MCTS
লুডু, backgammonহ্যাঁ (ছক্কা)নাexpectiminimax (অংশ ৮), MCTS
তাসের খেলা (ব্রিজ, পোকার)হ্যাঁ (তাস বাটা)হ্যাঁ (অন্যের হাতের তাস)এই পর্বের পদ্ধতি সরাসরি চলে না; ভাবনার প্রশ্ন ৪
দরদাম, নিলামপ্রায়ইহ্যাঁzero-sum নয়; game theory-র অন্য হাতিয়ার

একটি খেলার আনুষ্ঠানিক সংজ্ঞা

পর্ব ৩৯-এ একটি সমস্যাকে state, action, transition আর goal দিয়ে লিখেছিলেন, আর সব state মিলে একটি state space. খেলার জন্য এর সঙ্গে দুটি জিনিস যোগ হয়: কার পালা, আর শেষে কে কত পেল। Tic-tac-toe দিয়ে ছয়টি অংশ:

অংশঅর্থTic-tac-toe-তে
Initial state s0খেলা শুরুর অবস্থাখালি ৩×৩ বোর্ড; কোডে ".........", ৯টি ঘর
Player(s)অবস্থা s-এ কার চালX আর O সমান সংখ্যায় থাকলে X, নইলে O
Actions(s)বৈধ চালের তালিকাখালি ঘরগুলোর নম্বর (0 থেকে 8)
Result(s, a)চাল a দেওয়ার পরের অবস্থাঘর a-তে বর্তমান খেলোয়াড়ের চিহ্ন বসানো
Terminal test(s)খেলা শেষ কি নাকোনো সারি, কলাম বা কর্ণে তিনটি এক চিহ্ন, অথবা বোর্ড ভরা
Utility(s)শেষ অবস্থায় MAX-এর প্রাপ্তিX জিতলে +1, O জিতলে −1, ড্র 0

খেয়াল করুন, utility সবসময় MAX-এর চোখে: O জিতলে −1, যা MIN-এর জন্য ভালো। Zero-sum বলে একটি সংখ্যাতেই দুজনের মনোভাব ধরা যায়। এই ছয়টি অংশই নিচের কোডে ছয়টি ছোট function.

Tic-tac-toe-এর game tree, প্রথম কয়েক স্তর MAXMINMAXশেষ X X X XO XO কোণা ×৪মাঝ ×১ধার ×৪ কোণা ×৪ধার ×৪ খালি বোর্ড +1 X জেতে 0 ড্র −1 O জেতে
ছবি ৪২.১ — Game tree-র প্রতিটি স্তরে পালা বদলায়: MAX, MIN, MAX… শুধু leaf-এ utility জানা; ভেতরের node-এর মান minimax দিয়ে বের করতে হয়। প্রতিটি স্তরকে বলে এক ply.

২ · খেলার গাছ কত বড়

প্রথম চালে X-এর ৯টি বিকল্প, তারপর O-এর ৮টি, তারপর ৭টি… খেলা কখনো আগে শেষ না হলে চালের মোট ক্রম হতো 9! = 9 × 8 × … × 1 = ৩,৬২,৮৮০টি (factorial, পর্ব ৩৫)। কিন্তু অনেক খেলা পাঁচ, ছয় বা সাত চালেই কেউ জিতে শেষ হয়, তখন বাকি চাল আর দেওয়া হয় না। তাই আসল সংখ্যা কম। লেখকের যন্ত্রে পুরো গাছ গুনে পাওয়া গেছে (অংশ ৪-এর কোড একই সংখ্যা পাতাতেই বের করে):

চালের সংখ্যা (গভীরতা)গাছে node (চালের ক্রম)আলাদা বোর্ড-অবস্থাএই চালে শেষ হওয়া খেলা
011—
199—
27272—
3504252—
43,024756—
515,1201,2601,440
654,7201,5205,328
7148,1761,14047,952
8200,44839072,576
9127,87278127,872
মোট549,9465,478255,168

তিনটি কলাম তিনটি আলাদা প্রশ্নের উত্তর। আলাদা খেলা ২,৫৫,১৬৮টি: শুরু থেকে শেষ পর্যন্ত চালের সম্পূর্ণ ক্রম (X জেতে ১,৩১,১৮৪টিতে, O ৭৭,৯০৪টিতে, ড্র ৪৬,০৮০)। গাছের node ৫,৪৯,৯৪৬টি: সাধারণ minimax যতবার একটি অবস্থায় ঢোকে। আলাদা বোর্ড-অবস্থা মাত্র ৫,৪৭৮টি, কারণ ভিন্ন ক্রমের চালে একই বোর্ডে পৌঁছানো যায় (X কোণায় তারপর মাঝে, বা উল্টো)। ছয় চালে node আলাদা অবস্থার প্রায় ৩৬ গুণ; এই ফাঁকটিই অংশ ৪-এ memoization কাজে লাগাবে। (সমমিতি, মানে বোর্ড ঘোরানো বা উল্টানো, এক ধরা হয়নি।)

মূল ধারণা

গড়ে b-টি চাল (branching factor) আর m গভীরতার গাছে প্রায় bmটি leaf. Search-এর খরচ এই সংখ্যার সঙ্গে বাড়ে; একে আগের পর্বগুলোতে combinatorial explosion বলা হয়েছে। খেলায় বাড়তি সমস্যা হলো, আপনি পথটি নিজে বেছে নিতে পারেন না: প্রতিপক্ষের সব সম্ভাব্য জবাবের জন্য উত্তর তৈরি রাখতে হয়। তাই খেলার “সমাধান” একটি পথ নয়, একটি কৌশল (strategy): প্রতিটি সম্ভাব্য অবস্থায় কী চাল দেবেন।

দাবায় সংখ্যাগুলো গোনা যায় না, শুধু অনুমান করা যায়। Claude Shannon ১৯৫০ সালে মোটা দাগে ধরেছিলেন, প্রতি চালে প্রায় ৩০টি বিকল্প আর খেলায় প্রায় ৪০ জোড়া চাল, ফলে চালের ক্রম প্রায় 10120; এটি এখন “Shannon number” নামে উদ্ধৃত হয়, তবে এটি একটি মোটা অনুমান, গণনা নয়। দাবার বৈধ অবস্থার নানা অনুমান 1043 থেকে 1047-এর মতো পরিসরে, আর Go-র বৈধ অবস্থা প্রায় 2 × 10170 বলে গণনা প্রকাশিত হয়েছে (John Tromp ও সহকর্মীরা, ২০১৬)। শিক্ষাটি একই: বড় খেলার পুরো গাছ কোনো কম্পিউটার দেখতে পারবে না.

১৯৫০ সালে Claude Shannon “Programming a Computer for Playing Chess” প্রবন্ধে (Philosophical Magazine) আজকের খেলার প্রোগ্রামের প্রায় পুরো কাঠামো দেন: একটি মূল্যায়ন function, কয়েক চাল গভীর minimax, আর দুই ধরনের কৌশল। “Type A” সব চাল নির্দিষ্ট গভীরতা পর্যন্ত দেখে; “Type B” সম্ভাবনাময় চাল বেছে আরও গভীরে যায় (ভাবনার প্রশ্ন ১)। Minimax-এর গাণিতিক ভিত্তি আরও পুরোনো: John von Neumann ১৯২৮ সালে zero-sum খেলার minimax theorem প্রমাণ করেন।

৩ · Minimax — হাতে দুই ply-এর গাছ, তারপর কোড

ক্রিকেটের অধিনায়কের কথা ভাবুন। তিনি ফিল্ডিং সাজানোর সময় ভাবেন না “ব্যাটসম্যান হয়তো ভুল শট খেলবে”; ভাবেন “ব্যাটসম্যান সবচেয়ে ভালো শটটি খেললে কী হবে”। Minimax ঠিক এই সতর্ক চিন্তা: প্রতিপক্ষ সবসময় আপনার জন্য সবচেয়ে খারাপ জবাবটি দেবে, এটা ধরে নিয়ে নিজের সেরা চাল বাছা।

নিয়মটি recursive (নিজেকে ডাকা):

Minimax(s) = Utility(s), যদি s শেষ অবস্থা
= maxa Minimax(Result(s, a)), যদি MAX-এর পালা
= mina Minimax(Result(s, a)), যদি MIN-এর পালা

এখানে maxa মানে “সব বৈধ চাল a-এর মধ্যে সবচেয়ে বড়”। Leaf থেকে শুরু করে মানগুলো এক স্তর এক স্তর করে ওপরে ওঠে, যাকে বলে backing up. গাছটি DFS-এর ক্রমে ঘোরা হয় (DFS), তাই memory লাগে শুধু গভীরতার সমান, O(bm), কিন্তু সময় লাগে O(bm).

নিচের ছোট গাছটি কোনো আসল খেলার নয়, হাতে হিসাবের জন্য বানানো। Root-এ MAX-এর তিনটি চাল A, B, C; প্রতিটির পর MIN-এর তিনটি জবাব, আর leaf-এর সংখ্যাগুলো MAX-এর চোখে utility. “পরে” চেপে মান ওপরে উঠতে দেখুন।

দুই ply-এর minimax গাছ MAX ABC 586 397 1042
  1. শুরু। শুধু নিচের ৯টি leaf-এর মান জানা, কারণ সেখানে খেলা শেষ। ভেতরের চারটি node-এর মান এখনো “?”. MAX-এর পালা root-এ (ওপরমুখী ত্রিভুজ), MIN-এর পালা A, B, C-তে (নিচমুখী ত্রিভুজ)।
  2. A. MAX যদি A খেলে, তারপর MIN-এর পালা। MIN তিনটি জবাবের মধ্যে MAX-এর জন্য সবচেয়ে খারাপটি নেবে: min(5, 8, 6) = 5. 8-এর লোভ বৃথা, MIN সেখানে যাবে না।
  3. B. min(3, 9, 7) = 3. B-তে একটি 9 আছে বলে B আকর্ষণীয় দেখায়, কিন্তু MIN 3-এ টেনে নামাবে।
  4. C. min(10, 4, 2) = 2. গাছের সবচেয়ে বড় leaf 10 এখানে, তবু C-র মান সবচেয়ে কম। “সবচেয়ে বড় সম্ভাবনা” আর “নিশ্চিত প্রাপ্তি” আলাদা জিনিস।
  5. Root. MAX তিনটি নিশ্চিত প্রাপ্তির সবচেয়ে বড়টি নেয়: max(5, 3, 2) = 5, চাল A. প্রতিপক্ষ নিখুঁত খেললে MAX অন্তত 5 পাবে, আর প্রতিপক্ষ ভুল করলে আরও বেশি। এই নিশ্চয়তাই minimax-এর মূল্য।

এবার একই গাছ কোডে। নিচের function গাছটিকে nested Python dict আর list হিসেবে নেয়, recursive ভাবে প্রতিটি node-এর মান বের করে আর তা ছাপে। ছাপার ক্রম খেয়াল করুন: সন্তানের মান আগে, মা-বাবার পরে, ঠিক backing up-এর মতো। প্রথমবার চালালে ব্রাউজারে Python নামতে কয়েক সেকেন্ড লাগতে পারে।

tree = {"A": [5, 8, 6], "B": [3, 9, 7], "C": [10, 4, 2]}   # MIN-এর তিনটি node

def minimax(node, is_max, name="root", depth=0):
    if isinstance(node, int):                 # leaf: খেলা শেষ, utility জানা
        return node
    if isinstance(node, dict):
        items = list(node.items())
    else:
        items = [(f"{name}[{i}]", x) for i, x in enumerate(node)]
    vals = {k: minimax(v, not is_max, k, depth + 1) for k, v in items}
    best = max(vals.values()) if is_max else min(vals.values())
    who = "MAX" if is_max else "MIN"
    print("    " * depth + f"{who} {name}: সন্তানের মান {list(vals.values())} -> {best}")
    return best

value = minimax(tree, True)
print("root-এর মান:", value)
moves = {k: min(v) for k, v in tree.items()}
print("MAX-এর সেরা চাল:", max(moves, key=moves.get))

আসল আউটপুট:

    MIN A: সন্তানের মান [5, 8, 6] -> 5
    MIN B: সন্তানের মান [3, 9, 7] -> 3
    MIN C: সন্তানের মান [10, 4, 2] -> 2
MAX root: সন্তানের মান [5, 3, 2] -> 5
root-এর মান: 5
MAX-এর সেরা চাল: A

Stepper-এর প্রতিটি সংখ্যা মিলেছে। ভেতরে সরানো (indent করা) তিনটি লাইন MIN স্তরের, শেষে root. is_max প্রতিটি স্তরে উল্টে যায় (not is_max), তাই একই function দুই খেলোয়াড়ের কাজ করে। Leaf হলে (int) সরাসরি মান ফেরত, এটাই terminal test আর utility. শেষ দুই লাইন সেরা চালটির নাম বের করে; বড় প্রোগ্রামে minimax সাধারণত মানের সঙ্গে চালটিও ফেরত দেয়।

৪ · Tic-tac-toe পুরোটা সমাধান — memoization আর ড্র

এবার আসল খেলা। অংশ ১-এর ছয়টি অংশ এখানে ছয়টি ছোট function: player, actions, result, terminal, utility আর বোর্ড একটি ৯ অক্ষরের string ("X...O...X" মানে ঘর 0 আর 8-এ X, ঘর 4-এ O; ঘরের নম্বর বাঁ থেকে ডানে, ওপর থেকে নিচে 0 থেকে 8).

অংশ ২-এ দেখেছেন, গাছে ৫,৪৯,৯৪৬টি node থাকলেও আলাদা বোর্ড মাত্র ৫,৪৭৮টি। তাই একই বোর্ডের minimax মান বারবার হিসাব করা অপচয়। সমাধান memoization: প্রথমবার হিসাবের পর উত্তর একটি dict-এ রেখে দেওয়া, পরের বার সেখান থেকে নেওয়া। Python-এ functools.lru_cache decorator ঠিক এটি করে (Python কোর্সের functional tools পর্ব); DSA কোর্সে একই ধারণা dynamic programming-এর ভিত্তি নামে আছে। খেলার প্রোগ্রামে এই মনে রাখার টেবিলকে বলে transposition table. বোর্ড string বলে hash করা যায়, তাই cache-এর key হিসেবে সরাসরি চলে।

নিচের কোড চারটি কাজ করে: খালি বোর্ডের minimax মান, memo-তে কতগুলো আলাদা অবস্থা জমা হলো, পুরো গাছের খেলা ও node গোনা (tally, এটিও memo দিয়ে, তাই ৫ লাখ node আসলে না ঘুরেই গোনা যায়), আর একটি নির্দিষ্ট অবস্থায় সেরা চাল। এর পরের দুটি কোড ব্লক এই function-গুলোই ব্যবহার করবে (data-shared), তাই প্রথমে এটি চালান।

from functools import lru_cache
LINES = [(0,1,2), (3,4,5), (6,7,8), (0,3,6), (1,4,7), (2,5,8), (0,4,8), (2,4,6)]
def winner(b):
    for i, j, k in LINES:
        if b[i] != "." and b[i] == b[j] == b[k]:
            return b[i]
    return None
def player(b):    return "X" if b.count("X") == b.count("O") else "O"
def actions(b):   return [i for i, c in enumerate(b) if c == "."]
def result(b, a): return b[:a] + player(b) + b[a+1:]
def terminal(b):  return winner(b) is not None or "." not in b
def utility(b):   return {"X": 1, "O": -1, None: 0}[winner(b)]
@lru_cache(maxsize=None)              # memoization: একই অবস্থা দ্বিতীয়বার হিসাব নয়
def minimax(b):
    if terminal(b):
        return utility(b)
    vals = [minimax(result(b, a)) for a in actions(b)]
    return max(vals) if player(b) == "X" else min(vals)
@lru_cache(maxsize=None)
def tally(b):                         # (খেলা, X জেতে, O জেতে, ড্র, গাছের node)
    if terminal(b):
        u = utility(b)
        return (1, int(u == 1), int(u == -1), int(u == 0), 1)
    s = [sum(t) for t in zip(*(tally(result(b, a)) for a in actions(b)))]
    return (*s[:4], s[4] + 1)
empty = "." * 9
print("খালি বোর্ডের minimax মান:", minimax(empty))
print("memo-তে আলাদা অবস্থা:", minimax.cache_info().currsize)
print("খেলা, X জয়, O জয়, ড্র, গাছের node:", tally(empty))
pos = "X...O...X"                     # X: 0 আর 8, O: 4; এখন O-র চাল
vals = {a: minimax(result(pos, a)) for a in actions(pos)}
print("O-র চালগুলোর মান:", vals)
print("O-র নিরাপদ চাল:", [a for a, v in vals.items() if v == min(vals.values())])

আসল আউটপুট:

খালি বোর্ডের minimax মান: 0
memo-তে আলাদা অবস্থা: 5478
খেলা, X জয়, O জয়, ড্র, গাছের node: (255168, 131184, 77904, 46080, 549946)
O-র চালগুলোর মান: {1: 0, 2: 1, 3: 0, 5: 0, 6: 1, 7: 0}
O-র নিরাপদ চাল: [1, 3, 5, 7]

প্রথম লাইনই এই খেলার পূর্ণ সমাধান: খালি বোর্ডের মান 0, মানে দুজনেই নিখুঁত খেললে ড্র; tic-tac-toe তাই একটি solved game. Memo-তে ঠিক ৫,৪৭৮টি অবস্থা, অংশ ২-এর সংখ্যা; সাধারণ minimax হলে ৫,৪৯,৯৪৬ বার function ডাকতে হতো (তৃতীয় লাইনের শেষ সংখ্যা)। লেখকের যন্ত্রে memo ছাড়া minimax প্রায় ০.৬ সেকেন্ড, memo-সহ প্রায় ০.০১ সেকেন্ড নিয়েছে (একটি স্থানীয় run, Python 3.14). এলোমেলো খেলায় X বেশি জেতে (১,৩১,১৮৪ বনাম ৭৭,৯০৪), কারণ X আগে খেলে; কিন্তু নিখুঁত খেলায় সেই সুবিধা জয়ে বদলায় না।

শেষ দুই লাইন একটি পরিচিত ফাঁদ। X দুই বিপরীত কোণায়, O মাঝে। O কোণায় (ঘর 2 বা 6) খেললে মান +1: X তখন একসঙ্গে দুটি হুমকি (fork) তৈরি করে জেতে। ধারে (1, 3, 5, 7) খেললে ড্র নিশ্চিত। “কোণা ভালো ঘর” প্রবাদ এখানে ভুল; minimax শুধু হিসাব মানে। অংশ ৯-এ দেখবেন, শুধু এলোমেলো playout-এর গড় দেখলে কম্পিউটারও এই ফাঁদে পড়ে।

“মান 0” মানে “যেকোনো চাল চলবে” নয়। খালি বোর্ডে X-এর ৯টি প্রথম চালেরই minimax মান 0, কিন্তু মানুষ প্রতিপক্ষের বিরুদ্ধে কিছু চালে ভুলের সুযোগ বেশি; প্রতিপক্ষকে নিখুঁত ধরে বলে minimax তা দেখে না (ভাবনার প্রশ্ন ৩)।

৫ · Alpha-beta pruning — α, β আর কাটা শাখা

অংশ ৩-এর গাছে ফিরে যান। A দেখার পর MAX জানে, root-এ অন্তত 5 নিশ্চিত। এবার B-র প্রথম leaf 3. MIN B-তে অন্তত 3-এ নামাতে পারবে, হয়তো আরও কম, কিন্তু কখনো 3-এর বেশি দেবে না। তাহলে B-র মান ≤ 3 < 5, আর MAX কখনো B খেলবে না। B-র বাকি দুটি leaf (9 আর 7) যা-ই হোক, root-এর সিদ্ধান্ত বদলাবে না। তাদের দেখারই দরকার নেই। এটিই alpha-beta pruning-এর পুরো ধারণা।

বাজারে ইলিশ কিনছেন। প্রথম দোকানদার ৯০০ টাকায় রাজি। দ্বিতীয়জন শুরুতেই বললেন “১,২০০-এর নিচে না”, আর তিনি দাম শুধু বাড়াতে পারেন। তাঁর সঙ্গে আর দরদাম কেন? যে বিকল্প নিশ্চিতভাবে আগের প্রস্তাবের চেয়ে খারাপ, তার বাকি খুঁটিনাটি জানার দরকার নেই।

চলার পথে দুটি সংখ্যা সঙ্গে রাখা হয়:

  • α (alpha): এ পর্যন্ত পাওয়া পথে MAX নিশ্চিতভাবে যত পেতে পারে, তার সেরা মান। শুরুতে −∞; শুধু MAX node-এ বাড়ে।
  • β (beta): এ পর্যন্ত পাওয়া পথে MIN নিশ্চিতভাবে MAX-কে যত কমে আটকে রাখতে পারে, তার সেরা মান। শুরুতে +∞; শুধু MIN node-এ কমে।
  • কাটার নিয়ম: কোনো node-এ α ≥ β হলেই বাকি সন্তানদের দেখা বন্ধ। MIN node-এ এটি ঘটলে বলে α-cut, MAX node-এ β-cut.

α আর β মিলে একটি “জানালা” (α, β): এই জানালার বাইরের কোনো মান root পর্যন্ত পৌঁছাতে পারে না। নিচে একই গাছে leaf এক এক করে দেখুন।

Alpha-beta trace MAX ABC 586 397 1042
  1. শুরু। Root-এ α = −∞, β = +∞: কিছুই নিশ্চিত নয়।
  2. A-র প্রথম leaf 5. MIN এখন অন্তত 5-এ নামাতে পারে, তাই A-তে β = 5, A-র মান ≤ 5. α = −∞ < β, কাটা নেই।
  3. A-র দ্বিতীয় leaf 8. 8 > 5, MIN এটি নেবে না; β থাকে 5.
  4. A-র তৃতীয় leaf 6. A-র মান ঠিক 5. Root-এ ফিরে MAX-এর নিশ্চিত প্রাপ্তি α = max(−∞, 5) = 5. এখন থেকে root-এর নিচে যেকোনো MIN node-এ জানালা (5, +∞)।
  5. B-র প্রথম leaf 3. B-তে β = 3. শর্ত α = 5 ≥ β = 3 সত্য, তাই α-cut: B-র মান ≤ 3, যা A-র 5-এর চেয়ে খারাপ। B-র বাকি দুটি leaf, 9 আর 7, দেখাই হয় না (লাল কাটা)। 9 থাকলেও MIN সেখানে যেত না।
  6. C-র প্রথম leaf 10. C-তে β = 10; α = 5 < 10, তাই এখনো বলা যায় না C খারাপ কি না। পরের leaf দেখতে হবে।
  7. C-র দ্বিতীয় leaf 4. β = 4 ≤ α = 5, আবার কাটা; C-র শেষ leaf 2 দেখা হয় না। ওই 2-ই ছিল C-র আসল মান, কিন্তু “≤ 4” জানাই যথেষ্ট।
  8. ফল। Root = max(5, ≤3, ≤4) = 5, চাল A, হুবহু minimax-এর উত্তর। ৯টির মধ্যে ৬টি leaf দেখা হয়েছে, ৩টি বাদ। B আর C-র মান আমরা ঠিক জানি না, শুধু সীমা জানি; এটি পরে transposition table-এ গুরুত্বপূর্ণ (আরও গভীরে দেখুন)।
মূল ধারণা

Alpha-beta কখনো উত্তর বদলায় না, শুধু এমন শাখা বাদ দেয় যা প্রমাণযোগ্যভাবে root-এর মানে প্রভাব ফেলতে পারে না। এটি অনুমান বা heuristic নয়, একটি নির্ভুল কৌশল। তবে কাটা পড়া node-গুলোর যে মান function ফেরত দেয়, তা অনেক সময় সঠিক মান নয়, শুধু একটি সীমা (উপরে B-র “≤ 3”). Root-এর মান আর সেরা চাল সবসময় ঠিক থাকে।

৬ · চালের ক্রম — কেন একই গাছে কখনো অনেক, কখনো সামান্য কাটা

অংশ ৫-এর গাছে leaf-গুলো যদি উল্টো ক্রমে থাকত, ধরুন A-র জায়গায় C আগে আসত, তাহলে প্রথমে α হতো 2, আর পরে কাটার সুযোগ কমে যেত। কাটা হয় তখনই, যখন সেরা চালটি আগে দেখা হয়: তাড়াতাড়ি একটি ভালো α বা β পেলে বাকি খারাপ বিকল্প দ্রুত বাদ পড়ে। একে বলে move ordering.

Donald Knuth আর Ronald Moore ১৯৭৫ সালের একটি বিখ্যাত বিশ্লেষণে দেখান, b branching factor আর d গভীরতার সমান-আকারের গাছে নিখুঁত ক্রম পেলে alpha-beta ঠিক b⌈d/2⌉ + b⌊d/2⌋ − 1টি leaf দেখে (⌈ ⌉ মানে ওপরের দিকে, ⌊ ⌋ মানে নিচের দিকে পূর্ণসংখ্যায় গোল করা)। এটি মোটামুটি O(bd/2), মানে minimax-এর bd-এর বর্গমূল। একই সময়ে প্রায় দ্বিগুণ গভীরে দেখা যায়। খারাপ ক্রমে কাটা প্রায় হয় না, খরচ minimax-এর কাছাকাছি, O(bd). এলোমেলো ক্রমে মাঝামাঝি, প্রায়ই O(b3d/4)-এর কাছাকাছি বলে উদ্ধৃত হয়।

সূত্রটি লেখকের যন্ত্রে একটি বানানো গাছে যাচাই করা হলো: b = 4, d = 6, মানে ৪,০৯৬টি leaf, প্রতিটিতে seed 42 দিয়ে 0 থেকে 99-এর এলোমেলো সংখ্যা। “সেরা চাল আগে” ক্রমের জন্য আগে সাধারণ minimax দিয়ে আসল মান বের করা হয়েছে, যা বাস্তবে সম্ভব নয়; এটি শুধু তাত্ত্বিক সেরা অবস্থা।

$ python ordering_lab.py
minimax: মান 26 | leaf 4096
alpha-beta, 0,1,2,3 ক্রমে: মান 26 | leaf 687
alpha-beta, সেরা চাল আগে: মান 26 | leaf 127
alpha-beta, সবচেয়ে খারাপ চাল আগে: মান 26 | leaf 3568
Knuth-Moore সূত্র b^ceil(d/2) + b^floor(d/2) - 1 = 127

সত্যিকারের run (Python 3.14.4, শুধু standard library; স্ক্রিপ্টে random.Random(42), lru_cache আর অংশ ৫-এর মতো alpha-beta). চারটি পদ্ধতিরই মান 26, উত্তর এক। নিখুঁত ক্রমে ঠিক ১২৭টি leaf, সূত্রের সঙ্গে হুবহু: ৪,০৯৬-এর মাত্র ৩%. সাধারণ (0, 1, 2, 3) ক্রমে ৬৮৭টি, আর প্রতিটি স্তরে সবচেয়ে খারাপটি আগে দিলে ৩,৫৬৮টি, প্রায় পুরো গাছ।

এবার tic-tac-toe-তে। নিচের কোড একটি সাধারণ alpha-beta লেখে, যা যেকোনো গাছে চলে: শুধু বলে দিতে হয় কোনো অবস্থার সন্তান কারা (kids) আর শেষ অবস্থার মান কী (value). প্রথমে অংশ ৫-এর হাতের গাছে চালিয়ে stepper মেলানো হয়, তারপর খালি বোর্ড থেকে চার রকম ক্রমে। “মাঝ-কোণা-ধার” একটি সহজ heuristic: মাঝের ঘর চারটি লাইনে, কোণা তিনটিতে, ধার দুটিতে পড়ে, তাই মাঝ আগে দেখা স্বাভাবিক। “সেরা চাল আগে” আবার একটি তাত্ত্বিক সেরা অবস্থা, আগের ব্লকের memo করা minimax ব্যবহার করে।

import math
def alphabeta(s, alpha, beta, is_max, kids, value, stat):
    stat["node"] += 1
    ks = kids(s)
    if not ks:                                # terminal
        return value(s)
    v = -math.inf if is_max else math.inf
    for i, k in enumerate(ks):
        x = alphabeta(k, alpha, beta, not is_max, kids, value, stat)
        if is_max: v = max(v, x); alpha = max(alpha, v)
        else:      v = min(v, x); beta = min(beta, v)
        if alpha >= beta:                     # বাকি ভাইবোনদের দেখার দরকার নেই
            stat["cut"] += ks[i + 1:]
            break
    return v
hand = [[5, 8, 6], [3, 9, 7], [10, 4, 2]]
st = {"node": 0, "cut": []}
v = alphabeta(hand, -math.inf, math.inf, True,
              lambda t: t if isinstance(t, list) else [], lambda t: t, st)
print("হাতের গাছ: মান", v, "| node দেখা", st["node"], "of 13 | বাদ পড়া leaf", st["cut"])
def ttt_kids(order):
    return lambda b: [] if terminal(b) else [result(b, a) for a in order(b)]
def pref(p): return lambda b: [i for i in p if b[i] == "."]
orders = {"0..8 ক্রমে":     actions,
          "মাঝ-কোণা-ধার":  pref([4, 0, 2, 6, 8, 1, 3, 5, 7]),
          "ধার-কোণা-মাঝ":  pref([1, 3, 5, 7, 0, 2, 6, 8, 4]),
          "সেরা চাল আগে":  lambda b: sorted(actions(b), reverse=player(b) == "X",
                                            key=lambda a: minimax(result(b, a)))}
print("pruning ছাড়া minimax:", tally(empty)[4], "node")
for name, order in orders.items():
    st = {"node": 0, "cut": []}
    v = alphabeta(empty, -2, 2, True, ttt_kids(order), utility, st)
    print(f"alpha-beta, {name}: মান {v}, node {st['node']}")

আসল আউটপুট:

হাতের গাছ: মান 5 | node দেখা 10 of 13 | বাদ পড়া leaf [9, 7, 2]
pruning ছাড়া minimax: 549946 node
alpha-beta, 0..8 ক্রমে: মান 0, node 18297
alpha-beta, মাঝ-কোণা-ধার: মান 0, node 7275
alpha-beta, ধার-কোণা-মাঝ: মান 0, node 42094
alpha-beta, সেরা চাল আগে: মান 0, node 2312

প্রথম লাইন stepper-এর সঙ্গে মেলে: মান 5, বাদ পড়া leaf 9, 7 আর 2, আর ১৩টি node-এর (root, তিনটি MIN, নয়টি leaf) ১০টিতে ঢোকা হয়েছে। Tic-tac-toe-তে প্রতিটি ক্রমেই মান 0, minimax-এর মতোই। খরচ: pruning ছাড়া ৫,৪৯,৯৪৬ node; 0..8 ক্রমে ১৮,২৯৭; মাঝ-কোণা-ধারে ৭,২৭৫; উল্টো ধার-কোণা-মাঝে ৪২,০৯৪, প্রায় ছয় গুণ বেশি; তাত্ত্বিক সেরা ক্রমে ২,৩১২. লেখকের যন্ত্রে প্রতিটি স্তরে সবচেয়ে খারাপ চাল আগে দিলে ১,৯৮,৮৩৭ node লেগেছে। Utility −1 থেকে 1-এর বাইরে যায় না বলে শুরুর জানালা (−2, 2).

আরও গভীরে: বাস্তব খেলার প্রোগ্রামে ক্রম কোথা থেকে আসে? আসল মান তো আগে থেকে জানা নেই। প্রচলিত কৌশল: iterative deepening (গভীরতা 1, 2, 3… করে বাড়ানো; আগের গভীরতার সেরা চাল পরের বার আগে দেখা), দাবায় ঘুঁটি খাওয়ার চাল আগে, আর আগে কাটা ঘটানো চাল মনে রাখা (killer move, history heuristic). একই অবস্থা transposition table-এ রাখার সময় শুধু মান নয়, সেটি নিখুঁত মান, নিচের সীমা না ওপরের সীমা, তা-ও লিখতে হয়, নইলে ভুল ফল আসে। অনেক প্রোগ্রাম কোড ছোট করতে negamax লেখে, যেখানে max(a, b) = −min(−a, −b) কাজে লাগিয়ে প্রতিটি স্তরে চিহ্ন উল্টানো হয়।

৭ · Nim — search-এর উত্তর গণিতের সঙ্গে মেলানো

Search-এর কোড লিখলেন, উত্তরও এলো। কিন্তু উত্তর যে ঠিক, তা কীভাবে জানবেন? সবচেয়ে ভালো উপায় এমন একটি খেলা, যার উত্তর অন্য পথে আগে থেকেই জানা। Nim ঠিক তেমন খেলা। কয়েকটি স্তূপে দিয়াশলাইয়ের কাঠি রাখা। নিজের পালায় একটি স্তূপ বেছে সেখান থেকে যত খুশি (অন্তত একটি) কাঠি তুলতে হয়। যে শেষ কাঠিটি তোলে, সে জেতে।

Charles L. Bouton ১৯০১ সালে Annals of Mathematics-এ প্রকাশিত প্রবন্ধে এই খেলার পূর্ণ গাণিতিক সমাধান দেন। সব স্তূপের কাঠির সংখ্যার bitwise XOR (প্রতিটি bit আলাদা করে যোগ, ২ দিয়ে ভাগশেষ) নিন; একে বলে nim-sum. Bouton-এর নিয়ম: nim-sum শূন্য হলে যার চাল, সে হারবে (প্রতিপক্ষ নিখুঁত খেললে); শূন্য না হলে সে জিতবে, আর জেতার চাল হলো এমন একটি চাল যা nim-sum শূন্য করে দেয়।

হাতে একটি উদাহরণ: স্তূপ (3, 4, 5). Binary-তে 011, 100, 101. XOR: 011 ⊕ 100 = 111, তারপর 111 ⊕ 101 = 010, মানে nim-sum 2 ≠ 0, তাই প্রথমজন জিতবে। কোন স্তূপে চাল? যে স্তূপের h-এর জন্য h ⊕ 2 < h: 3 ⊕ 2 = 1 < 3, তাই প্রথম স্তূপ 3 থেকে 1-এ নামান (২টি কাঠি তুলুন)। নতুন অবস্থা (1, 4, 5): 001 ⊕ 100 ⊕ 101 = 000, প্রতিপক্ষ এখন হারার অবস্থায়।

এবার search দিয়ে একই প্রশ্ন, কোনো XOR না জেনে। একটি অবস্থা “জেতার” যদি এমন অন্তত একটি চাল থাকে যা প্রতিপক্ষকে “হারার” অবস্থায় পাঠায়; নইলে “হারার”। এটি আসলে minimax, শুধু utility +1/−1 আর মান সবসময় যার চাল তার চোখে (negamax-এর ধাঁচে)। স্তূপের ক্রম ফলে প্রভাব ফেলে না, তাই sorted করে অবস্থা কমানো হয়েছে। লেখকের যন্ত্রে সত্যিকারের run (Python 3.14.4):

from functools import lru_cache
from itertools import product

@lru_cache(maxsize=None)
def wins(heaps):                     # যার চাল, সে কি জোর করে জিততে পারে?
    for i, h in enumerate(heaps):
        for take in range(1, h + 1):
            nxt = tuple(sorted(heaps[:i] + (h - take,) + heaps[i + 1:]))
            if not wins(nxt):        # প্রতিপক্ষকে হারার অবস্থায় পাঠানো গেলে জয়
                return True
    return False                     # (0,0,0)-তে কোনো চাল নেই: আগের জন শেষ কাঠি নিয়েছে

def xor_rule(heaps):                 # Bouton (1901): nim-sum শূন্য না হলে জয়
    x = 0
    for h in heaps:
        x ^= h
    return x != 0

agree = total = 0
for hs in product(range(6), repeat=3):          # প্রতিটি স্তূপে 0 থেকে 5টি কাঠি
    total += 1
    agree += wins(tuple(sorted(hs))) == xor_rule(hs)
print("মিলেছে:", agree, "/", total, "| memo-তে অবস্থা:", wins.cache_info().currsize)
print("হারার অবস্থা:", sorted({tuple(sorted(h)) for h in product(range(6), repeat=3)
                                if not wins(tuple(sorted(h)))}))
h = (3, 4, 5)
x = 3 ^ 4 ^ 5
print("(3, 4, 5): nim-sum", x, "| জয়?", wins(h))
print("জেতার চাল:", [(i, v, v ^ x) for i, v in enumerate(h) if v ^ x < v])
$ python nim_check.py
মিলেছে: 216 / 216 | memo-তে অবস্থা: 56
হারার অবস্থা: [(0, 0, 0), (0, 1, 1), (0, 2, 2), (0, 3, 3), (0, 4, 4), (0, 5, 5), (1, 2, 3), (1, 4, 5)]
(3, 4, 5): nim-sum 2 | জয়? True
জেতার চাল: [(0, 3, 1)]

২১৬টি অবস্থার প্রতিটিতে search আর XOR নিয়ম একই কথা বলেছে; সাজানোর পর আলাদা অবস্থা মাত্র ৫৬টি। হারার অবস্থায় দুটি সমান স্তূপ (প্রতিপক্ষ শুধু আপনার চাল নকল করে), আর (1, 2, 3), (1, 4, 5), যেখানে XOR শূন্য। শেষ লাইন হাতের হিসাব মেলায়: স্তূপ 0, 3 থেকে 1.

মূল ধারণা

নিজের search কোড এমন সমস্যায় পরীক্ষা করুন, যার উত্তর অন্য পথে জানা: Nim-এর XOR, tic-tac-toe-এর ২,৫৫,১৬৮ আর ড্র, Knuth-Moore-এর ১২৭. আর গঠন জানা থাকলে brute-force লাগে না: স্তূপ যত বড়ই হোক, XOR এক মুহূর্তে। Search তখনই লাগে, যখন এমন নিয়ম জানা নেই, যেমন দাবায়।

৮ · বড় খেলা — গভীরতার সীমা, evaluation function, horizon effect আর দান

দাবায় alpha-beta নিখুঁত ক্রমেও খেলার শেষ পর্যন্ত দেখতে পারে না। তাই Shannon-এর প্রস্তাব মেনে প্রোগ্রাম একটি নির্দিষ্ট গভীরতায় থামে (depth-limited search), আর সেখানে আসল utility-র বদলে একটি evaluation function Eval(s) দিয়ে অবস্থার মান অনুমান করে। নিয়মটি হয়:

H-Minimax(s, d) = Utility(s), যদি s শেষ অবস্থা
= Eval(s), যদি d = গভীরতার সীমা
নইলে আগের মতো max বা min, d + 1 গভীরতায়

ভালো evaluation function শেষ অবস্থায় utility-র সঙ্গে একই ক্রম দেয়, দ্রুত হিসাব হয় (লাখ লাখ বার ডাকা হবে), আর জেতার সম্ভাবনার সঙ্গে শক্ত সম্পর্ক রাখে। দাবায় পুরোনো সহজ রূপ ঘুঁটির মানের যোগ (প্রচলিত মোটা অনুপাতে বোড়ে 1, ঘোড়া ও হাতি প্রায় 3, নৌকা 5, মন্ত্রী 9), সঙ্গে রাজার নিরাপত্তার মতো বিষয়ের ওজন। এগুলো heuristic: অভিজ্ঞতার আন্দাজ, প্রমাণ নয়।

Tic-tac-toe-এর একটি সহজ evaluation

একটি লাইন (সারি, কলাম বা কর্ণ, মোট ৮টি) X-এর জন্য “খোলা” যদি সেখানে কোনো O না থাকে। ধরা যাক Eval(s) = X-এর খোলা লাইন − O-এর খোলা লাইন। প্রথম চালের পর হিসাব:

X-এর প্রথম চালX-এর খোলা লাইনO-এর খোলা লাইনEval
মাঝে (ঘর 4)88 − 4 = 44
কোণায় (ঘর 0)88 − 3 = 53
ধারে (ঘর 1)88 − 2 = 62

এই evaluation-ই অংশ ৬-এর “মাঝ-কোণা-ধার” ক্রমের যুক্তি। লেখকের যন্ত্রে দুই ply গভীর হিসাবে (জয় বা হার ±100, যাতে আসল ফল অনুমানের চেয়ে ভারী) মাঝের চালের মান 1, কোণার −1, ধারের −2 এসেছে, তাই প্রোগ্রাম মাঝ বাছে। কিন্তু নিখুঁত হিসাবে সব প্রথম চালই 0: অগভীর search একটি পছন্দ দেয়, প্রমাণ নয়।

Horizon effect

ধরুন দাবায় প্রোগ্রাম ৬ ply দেখে, আর ৮ ply পরে তার মন্ত্রী হারানো অনিবার্য। প্রোগ্রাম তখন একটি বোড়ে বিসর্জন দিয়ে কয়েকটি কিস্তি দেয়, যাতে মন্ত্রী হারানো সীমার ওপারে চলে যায়; সীমার ভেতরে সে দেখে “বোড়ে গেল, মন্ত্রী আছে”। আসলে দুটিই যাবে। এর নাম horizon effect. প্রচলিত প্রতিকার quiescence search: সীমায় অবস্থা অস্থির হলে (ঘুঁটি খাওয়ার চাল বাকি) শুধু সেই চালগুলো দিয়ে আরও খোঁজা। এটি সমস্যা কমায়, দূর করে না।

Evaluation function-এর ভুল search ঢাকতে পারে না। Eval যদি শুধু ঘুঁটি গোনে আর রাজার বিপদ না দেখে, গভীর search সেই ভুলকেই দক্ষতার সঙ্গে কাজে লাগায়: অপটিমাইজার মাপকাঠিকে অনুসরণ করে, উদ্দেশ্যকে নয় (Goodhart's law-এর আত্মীয়)।

দানের খেলা: expectiminimax

লুডুতে চাল দেওয়ার আগে ছক্কা পড়ে। এমন stochastic খেলার গাছে MAX আর MIN node-এর মাঝে আসে chance node, যার মান সন্তানদের মানের সম্ভাবনা-ওজনসহ গড়, মানে expected value (পর্ব ৩৬): Value = ∑ P(r) × Value(Result(s, r)), যেখানে r ছক্কার একটি ফল। এটিই expectiminimax. একটি ছোট উদাহরণ: MAX-এর চাল ক নিশ্চিতভাবে 3 দেয়। চাল খ-এর পর একটি মুদ্রা টস: অর্ধেক সম্ভাবনায় 8, অর্ধেক সম্ভাবনায় −1. খ-এর মান 0.5 × 8 + 0.5 × (−1) = 3.5 > 3, তাই MAX খ নেবে।

এবার 8-এর জায়গায় 5 বসান। Leaf-গুলোর ক্রম একই থাকে (−1 < 3 < 5), কিন্তু খ-এর মান 0.5 × 5 + 0.5 × (−1) = 2 < 3, সিদ্ধান্ত উল্টে গেল। সাধারণ minimax-এ শুধু মানের ক্রম গুরুত্বপূর্ণ; expectiminimax-এ মানের আসল মাপও গুরুত্বপূর্ণ। তাই দানের খেলায় evaluation function-কে জেতার সম্ভাবনার সঙ্গে আনুপাতিক হতে হয়। অনিশ্চয়তায় সিদ্ধান্তের পুরো কাঠামো (expected utility আর MDP) পর্ব ৫৪-এ আসবে।

Arthur Samuel ১৯৫০-এর দশকে IBM-এ একটি checkers প্রোগ্রাম বানান, যা অল্প গভীরতার search-এর সঙ্গে evaluation function ব্যবহার করত আর নিজের খেলা থেকে তার ওজন সমন্বয় করত (পর্ব ২)। Jonathan Schaeffer-এর দল ২০০৭ সালে Science পত্রিকায় দেখায় যে checkers নিখুঁত খেলায় ড্র। ১৯৯৭ সালে IBM-এর Deep Blue Garry Kasparov-কে ছয় খেলার ম্যাচে ৩½–২½ ব্যবধানে হারায়; এর শক্তি ছিল বিশেষ chip-এ অত্যন্ত দ্রুত alpha-beta search (প্রচলিত উদ্ধৃতিতে প্রতি সেকেন্ডে প্রায় ২০ কোটি অবস্থা), হাতে বানানো evaluation আর opening-এর বই; neural network নয়।

৯ · Monte Carlo tree search — এলোমেলো খেলা থেকে চাল বাছা

Go-র বোর্ড ১৯×১৯, প্রতি চালে প্রায়ই দুইশোর বেশি বিকল্প, আর ভালো evaluation function বহু বছর কেউ বানাতে পারেনি; alpha-beta তাই সেখানে দুর্বল ছিল। বিকল্প ধারণা: অবস্থার মান হিসাব না করে অনেকবার এলোমেলো খেলে দেখা। দুজনেই এলোমেলো চালে খেলা শেষ করা হলো একটি playout; হাজারটি playout-এ যে চালের পর বেশি জয়, সেটি সম্ভবত ভালো। এটি পর্ব ৩৬-এর Monte Carlo simulation-এর ধারণা (DSA কোর্সের randomized অ্যালগরিদম পর্বও দেখুন)।

শুধু playout-এর গড় (একে বলে flat Monte Carlo) একটি দুর্বলতা রাখে: এলোমেলো প্রতিপক্ষ ভুল করে, তাই এমন চালও ভালো দেখায় যা নিখুঁত প্রতিপক্ষের বিরুদ্ধে হারে। Monte Carlo tree search (MCTS) এর সমাধান দেয় ধীরে ধীরে একটি গাছ বানিয়ে, প্রতিটি পুনরাবৃত্তিতে চারটি ধাপে:

  1. Selection: root থেকে একটি সূত্র দিয়ে সন্তান বেছে নিচে নামা।
  2. Expansion: একটি নতুন সন্তান গাছে যোগ।
  3. Simulation: সেখান থেকে একটি playout.
  4. Backup: পথের প্রতিটি node-এ ভিজিট n আর জয় w হালনাগাদ।

Selection-এর প্রচলিত সূত্র UCB1 (Auer, Cesa-Bianchi আর Fischer, ২০০২), গাছে প্রয়োগের নাম UCT (Kocsis আর Szepesvári, ২০০৬)। সূত্রটি শুধু দেখানো হচ্ছে, প্রমাণ নয়:

UCB1(সন্তান i) = wi / ni + c √( ln N / ni )

wi হলো সন্তানটির জয়ের সংখ্যা (ড্র অর্ধেক), ni তার ভিজিট, N মা-বাবার ভিজিট, ln স্বাভাবিক logarithm, আর c একটি ধ্রুবক (প্রায়ই √2 ≈ 1.414). প্রথম পদ এ পর্যন্ত যে চাল ভালো ফল দিয়েছে তাকে টানে (exploitation); দ্বিতীয় পদ কম চেষ্টা করা চালকে সুযোগ দেয়, কারণ ni ছোট হলে পদটি বড় (exploration). এই টানাপোড়েনের নাম exploration–exploitation trade-off, যা reinforcement learning-এও কেন্দ্রীয় (পর্ব ৫৫)। নিচে একটি পুনরাবৃত্তি সংখ্যাসহ দেখুন।

MCTS-এর এক পুনরাবৃত্তি 4/5 2/3 abc c1 playout: জয় UCB 1.76UCB 1.91UCB 2.02
  1. গাছের বর্তমান অবস্থা। Root এ পর্যন্ত ১০ বার ভিজিট হয়েছে। Root-এর খেলোয়াড়ের চোখে a চালের পর ৫টি playout-এ ৪টি জয়, b-তে ৩টিতে ২টি, c-তে ২টিতে ১টি। গড় দেখলে a সেরা (0.80).
  2. Selection. UCB1, c = √2, ln 10 ≈ 2.3026: a = 0.80 + 1.414 × √(2.3026/5) ≈ 0.80 + 0.96 = 1.76; b = 0.67 + 1.24 = 1.91; c = 0.50 + 1.52 = 2.02. গড় সবচেয়ে কম হলেও c বাছা হলো, কারণ মাত্র ২ বার দেখা হয়েছে।
  3. Expansion. c-র পরের অবস্থায় প্রতিপক্ষের এখনো চেষ্টা না করা একটি চাল আছে; সেটি নতুন node c1 হিসেবে গাছে যোগ হলো, শুরুতে 0/0.
  4. Simulation. c1 থেকে এলোমেলো চালে খেলা শেষ; এই playout-এ root-এর খেলোয়াড় জিতল। একটি playout কম তথ্য দেয়, হাজারটির গড় অর্থবহ।
  5. Backup. পথের প্রতিটি node-এ n এক বাড়ে। প্রতিটি node-এর w লেখা হয় সেই খেলোয়াড়ের চোখে, যে চাল দিয়ে ওই node-এ এসেছে: c-তে root-এর খেলোয়াড়, তাই জয় যোগ, c হলো 2/3; c1-এ প্রতিপক্ষ, যে হেরেছে, তাই 0/1; root হলো N = 11. বাজেট শেষে সাধারণত সবচেয়ে বেশি ভিজিট হওয়া সন্তানটি চাল হিসেবে বাছা হয়।

এবার অংশ ৪-এর ফাঁদের অবস্থাটি ("X...O...X", O-র চাল, কোণা খেললে হার, ধার খেললে ড্র) দুই পদ্ধতিতে দেওয়া যাক: প্রতিটি চালের পর ১,০০০টি playout-এর flat Monte Carlo, আর ৩,০০০ পুনরাবৃত্তির UCT. দুটিই seed দেওয়া random.Random(1) ব্যবহার করে, তাই প্রতিবার একই ফল। কোডটি অংশ ৪-এর function-গুলোর ওপর দাঁড়িয়ে (data-shared).

import math, random
def playout(b, rng):                      # এলোমেলো চালে খেলা শেষ পর্যন্ত
    while not terminal(b):
        b = result(b, rng.choice(actions(b)))
    return winner(b)
def score(w, me): return 1 if w == me else 0.5 if w is None else 0
def flat_mc(b, n, rng):                   # প্রতিটি চালের পর n টি playout-এর গড়
    me = player(b)
    return {a: sum(score(playout(result(b, a), rng), me) for _ in range(n)) / n
            for a in actions(b)}
class Node:
    def __init__(s, b, parent=None):
        s.b, s.parent, s.kids, s.n, s.w = b, parent, {}, 0, 0.0
        s.todo = [] if terminal(b) else actions(b)
def uct(b, iters, rng, c=math.sqrt(2)):
    root = Node(b)
    for _ in range(iters):
        node = root
        while not node.todo and node.kids:                       # ১. select
            node = max(node.kids.values(), key=lambda k:
                       k.w / k.n + c * math.sqrt(math.log(node.n) / k.n))
        if node.todo:                                            # ২. expand
            a = node.todo.pop(rng.randrange(len(node.todo)))
            child = Node(result(node.b, a), node)
            node.kids[a] = child; node = child
        w = playout(node.b, rng)                                 # ৩. simulate
        while node:                                              # ৪. back up
            mover = "O" if player(node.b) == "X" else "X"        # যে এই node-এ চাল দিল
            node.n += 1; node.w += score(w, mover); node = node.parent
    return {a: k.n for a, k in root.kids.items()}
pos = "X...O...X"
f = flat_mc(pos, 1000, random.Random(1))
print("flat MC গড়:", {a: round(v, 3) for a, v in f.items()}, "-> বাছাই", max(f, key=f.get))
u = uct(pos, 3000, random.Random(1))
print("UCT-এর ভিজিট:", dict(sorted(u.items())), "-> বাছাই", max(u, key=u.get))

আসল আউটপুট:

flat MC গড়: {1: 0.553, 2: 0.589, 3: 0.563, 5: 0.546, 6: 0.544, 7: 0.528} -> বাছাই 2
UCT-এর ভিজিট: {1: 698, 2: 143, 3: 671, 5: 662, 6: 147, 7: 679} -> বাছাই 1

Flat Monte Carlo ফাঁদে পড়েছে: সবচেয়ে বেশি গড় ঘর 2-এর (0.589), যা minimax অনুযায়ী হারার চাল। এলোমেলো X প্রায়ই fork খুঁজে পায় না, তাই কোণা ভালো দেখায়; গড়ের পার্থক্যও খুব ছোট (0.53 থেকে 0.59). লেখকের যন্ত্রে seed 0 থেকে 4-এ flat MC প্রতিবার একটি কোণা (2 বা 6) বেছেছে।

UCT ঠিক চাল বেছেছে: ঘর 1, একটি ধার। দুটি কোণা (2 আর 6) মাত্র ১৪৩ আর ১৪৭ বার ভিজিট পেয়েছে, চারটি ধার প্রায় ৭০০ করে। গাছ গভীর হলে X-এর fork গাছের ভেতরে ধরা পড়ে, আর selection তখন X-এর পক্ষ থেকেও সেরা জবাব বাছে; ফলে কোণার গড় নেমে যায়। লেখকের যন্ত্রে seed 0 থেকে 4-এ UCT প্রতিবার একটি ধার বেছেছে। এটি একটি ছোট খেলার স্থানীয় পরীক্ষা, সাধারণ ফল নয়।

“Monte Carlo tree search” নামটি Rémi Coulom-এর ২০০৬ সালের একটি প্রবন্ধ থেকে প্রচলিত হয়, প্রায় একই সময়ে Kocsis আর Szepesvári UCT প্রকাশ করেন। ২০১৬ সালের মার্চে DeepMind-এর AlphaGo সিউলে Lee Sedol-কে পাঁচ খেলার ম্যাচে ৪–১ ব্যবধানে হারায়। Silver ও সহকর্মীদের সেই বছরের Nature প্রবন্ধ অনুযায়ী AlphaGo MCTS-এর সঙ্গে দুটি শেখা neural network জুড়েছিল: policy network, যা সম্ভাবনাময় চাল দেখিয়ে selection-কে দিক দেয়, আর value network, যা playout-এর পাশাপাশি অবস্থার মান অনুমান করে। Network-গুলো মানুষের খেলা আর নিজের সঙ্গে খেলা (reinforcement learning) থেকে train হয়েছিল; পরের AlphaGo Zero (২০১৭) মানুষের খেলা ছাড়াই শেখে। Neural network পর্ব ৬৮-এ শুরু হবে, আর এমন মডেলের training পরের deep learning কোর্সে। মনে রাখুন, এই সাফল্য স্থির নিয়ম, স্পষ্ট ফল আর অসীম অনুশীলনের একটি খেলায়।

খেলা যখন বাস্তব প্রতিপক্ষের মডেল

নিরাপত্তায় আক্রমণকারী আর রক্ষাকারীকে দুই খেলোয়াড় ধরে “আক্রমণকারী সবচেয়ে খারাপ কাজটি করবে” ধরে নকশা করা minimax-এর মতো worst-case চিন্তা, যা cybersecurity কোর্সের threat modeling-এর মূল সুর; দরদাম বা নিলামেও প্রতিপক্ষের জবাব আগে ভাবা হয়।

কিন্তু বাস্তবের প্রতিপক্ষ সাধারণত zero-sum নয় (দরদামে দুজনই লাভবান হতে পারেন), তার utility অজানা, তথ্য লুকানো, আর মানুষ নিখুঁত হিসাব করে না। তখন worst-case অনুমান কখনো অতিরিক্ত ভীতু সিদ্ধান্ত দেয়। মানুষকে “প্রতিপক্ষ” হিসেবে মডেল করার নৈতিক দিক পরের AI ethics কোর্সে আসবে। মডেলটি চিন্তার হাতিয়ার, বাস্তবের হুবহু ছবি নয়।

পরীক্ষা ও ইন্টারভিউতে (বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় এ ধরনের প্রশ্ন সাধারণ):

  • Minimax-এর সময় আর memory? সময় O(bm), memory O(bm) (DFS-এর মতো)।
  • Alpha-beta কি কখনো minimax-এর চেয়ে ভিন্ন চাল দেয়? না; root-এর মান আর সেরা চাল একই, শুধু কম node দেখে।
  • নিখুঁত ক্রমে alpha-beta-র খরচ? প্রায় O(bm/2); সমান গাছে ঠিক b⌈m/2⌉ + b⌊m/2⌋ − 1টি leaf, তাই একই সময়ে প্রায় দ্বিগুণ গভীরতা।
  • Horizon effect কী, প্রতিকার কী? সীমার ওপারের বিপদ না দেখে ক্ষতি পিছিয়ে দেওয়া; quiescence search.
  • Chance node-এর মান? সন্তানদের মানের সম্ভাবনা-ওজনসহ গড় (expectiminimax).

১০ · AI-এর যুগে — AI সহকারীর লেখা খেলার search যাচাই

“Tic-tac-toe-এর জন্য alpha-beta লিখে দাও” বললে AI সহকারী কয়েক সেকেন্ডে চলনসই কোড দেবে। সমস্যা হলো, খেলার search-এর ভুলগুলো প্রায়ই চুপচাপ থাকে: প্রোগ্রাম চলে, চাল দেয়, শুধু মাঝে মাঝে ভুল চাল দেয়। AI সহকারীরা প্রায়ই যা করে:

  • দৃষ্টিকোণের ভুল: utility একবার X-এর চোখে, একবার “যার চাল” তার চোখে; negamax-এ চিহ্ন উল্টাতে ভুলে যাওয়া; দুই স্তরেই max.
  • Alpha-beta-র সূক্ষ্ম ভুল: লুপের ভেতরে হালনাগাদ করা α/β-র বদলে পুরোনো মান নিচে পাঠানো, >=-এর জায়গায় ভুল শর্ত, আর কাটা পড়া node-এর সীমাকে নিখুঁত মান ধরে cache-এ রাখা।
  • বোর্ড নষ্ট করা: একটি list-এ চাল বসিয়ে ফেরার সময় মুছতে ভুলে যাওয়া (undo নেই), বা mutable বোর্ডকে cache-এর key করা।
  • গভীরতার সীমার আগে terminal test না করা, ফলে জেতা অবস্থাকেও evaluation দিয়ে অনুমান করা।
  • ভুল দাবি: “Deep Blue neural network ব্যবহার করত”, “MCTS সবসময় সেরা চাল দেয়”, “tic-tac-toe-তে প্রথমজন জেতে”, বা seed ছাড়া একবারের playout-কে প্রমাণ বলা।

প্রম্পটে খেলার নিয়ম, utility-র দৃষ্টিকোণ আর যাচাইয়ের উপায় নিজেই বলে দিন:

আমি Python 3.14-এ শুধু standard library দিয়ে tic-tac-toe-এর alpha-beta লিখতে চাই। বোর্ড একটি ৯ অক্ষরের string, যেমন "X...O...X"; utility সবসময় X-এর চোখে: X জিতলে +1, O জিতলে -1, ড্র 0. ১. player, actions, result, terminal, utility আলাদা function-এ লেখো; বোর্ড কখনো বদলে দেবে না, নতুন string ফেরত দেবে। ২. alphabeta function একটি counter-এ দেখা node গুনবে। ৩. যাচাই হিসেবে দেখাও: খালি বোর্ডের মান 0; একই কোডের সাধারণ minimax আর alpha-beta ২০০টি এলোমেলো অবস্থায় একই মান দেয়; pruning ছাড়া node 549946 হয়। ৪. "X...O...X" অবস্থায় O-র প্রতিটি চালের মান ছাপাও। ৫. কোনো সংখ্যা বা ঐতিহাসিক দাবি নিশ্চিত না হলে বানিয়ে লিখবে না, সরাসরি বলবে।

AI-এর কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১Shannon ভেবেছিলেন বাছাই করা search (Type B) জিতবে, কিন্তু দাবায় বহু বছর প্রায় সব চাল দেখা (Type A-র কাছাকাছি) পদ্ধতিই এগিয়ে ছিল। কেন?

Shannon-এর যুক্তি ছিল স্বাভাবিক। একজন grandmaster প্রতিটি অবস্থায় হাতে গোনা কয়েকটি চাল ভাবেন, বাকিগুলো এক নজরে বাদ দেন। ১৯৫০ সালের কম্পিউটার প্রতি সেকেন্ডে খুব কম অবস্থা দেখতে পারত, তাই সব চাল কয়েক ply গভীরে দেখাটাও প্রায় অসম্ভব মনে হতো। মানুষের মতো বাছাই করাই একমাত্র পথ বলে মনে হয়েছিল।

পরের দশকগুলোতে তিনটি জিনিস হিসাব বদলে দেয়। প্রথমত হার্ডওয়্যার: কম্পিউটারের গতি বহু গুণ বেড়েছে, আর Deep Blue-র মতো যন্ত্রে বিশেষ chip বসানো হয়েছিল। দ্বিতীয়ত alpha-beta আর ভালো move ordering: অংশ ৬-এ দেখেছেন, নিখুঁত ক্রমে খরচ প্রায় বর্গমূলে নামে, মানে “সব চাল দেখা” আসলে সব চাল সমান গভীরে দেখা নয়। Iterative deepening, transposition table, killer move এগুলো এই খরচ আরও কমায়। তৃতীয়ত, বাছাই করার একটি লুকানো বিপদ আছে: যে চালটি বাদ দিলেন, সেটিই হয়তো সেরা ছিল। মানুষের মতো বাছাই নিয়ম লিখে দেওয়া খুব কঠিন, আর ভুল বাছাই ধরা পড়ে না। সব চাল দেখলে অন্তত সেই অংশে ভুল হয় না।

তবে গল্পটি “brute force জিতেছে” এত সরলও নয়। আধুনিক দাবার প্রোগ্রাম সব চাল সমান গভীরে দেখে না; অসম্ভাবনাময় চাল কম গভীরে দেখা (late move reductions, null-move pruning ইত্যাদি) এক ধরনের নরম বাছাই। আর Go-তে, যেখানে branching factor অনেক বড় আর evaluation কঠিন, MCTS আর শেখা policy network আসলে Type B-র ধারণাকেই ফিরিয়ে আনে: কোন চাল দেখার যোগ্য, তা একটি শেখা মডেল বলে দেয়। AlphaZero (DeepMind, ২০১৮ সালে Science-এ প্রকাশিত) দাবাতেও এই পদ্ধতি দেখিয়েছে, যদিও শক্তিশালী alpha-beta প্রোগ্রামগুলোও পরে শেখা evaluation জুড়ে এগিয়েছে। “শেখা বনাম search” তাই আসলে প্রতিযোগিতা নয়, মিশ্রণ।

মূল উপলব্ধি: কোন কৌশল জিতবে তা নির্ভর করে হিসাবের খরচ, অ্যালগরিদমের কৌশল আর ভালো বাছাই নিয়ম লেখা কতটা কঠিন, তার ওপর; আর এই হিসাব প্রযুক্তি বদলালে বদলায়।

প্র ০২Deep Blue দাবায় জিতেছিল ১৯৯৭ সালে, কিন্তু Go-তে একই রকম সাফল্য এলো প্রায় দুই দশক পরে। Go এত কঠিন ছিল কেন, আর “সমাধান করা” আর “মানুষকে হারানো” কি এক?

দুটি কারণ বারবার উল্লেখ করা হয়। প্রথমটি আকার। দাবায় প্রতি চালে গড় বিকল্প প্রায়ই ৩০ থেকে ৪০-এর মতো বলা হয়, Go-তে কয়েকশো। খেলাও অনেক লম্বা। অংশ ৬-এর হিসাবে, alpha-beta নিখুঁত ক্রমেও bd/2 দেখে; b বড় হলে একই সময়ে দেখা গভীরতা অনেক কমে যায়।

দ্বিতীয় কারণ আরও গভীর: evaluation function. দাবায় ঘুঁটি গুনে একটি মোটামুটি কাজের মূল্যায়ন পাওয়া যায়; এক মন্ত্রী বেশি মানে সাধারণত ভালো। Go-তে সব পাথর একই রকম, আর একটি দল “জীবিত” না “মৃত” তা বুঝতে অনেক সময় অনেক চাল সামনে পড়তে হয়। হাতে লেখা নিয়মে এই বোধ ধরা খুব কঠিন ছিল। MCTS এখানে কাজে লাগে, কারণ এটি evaluation function ছাড়াই playout-এর ফল দিয়ে মান অনুমান করে। AlphaGo এর সঙ্গে শেখা value network আর policy network যোগ করে, যা মানুষের খেলা আর নিজের সঙ্গে খেলা থেকে Go-র অবস্থা চিনতে শেখে।

এবার “সমাধান” শব্দটি। Tic-tac-toe পুরো সমাধান: অংশ ৪-এর কোড প্রমাণ করে নিখুঁত খেলায় ড্র। Connect Four ১৯৮৮ সালে (James D. Allen আর Victor Allis আলাদাভাবে) সমাধান হয়েছে: প্রথমজন মাঝের কলামে শুরু করলে জেতে। Checkers ২০০৭ সালে, ড্র। কিন্তু দাবা আর Go সমাধান হয়নি; কেউ জানে না নিখুঁত খেলায় ফল কী। Deep Blue বা AlphaGo শুধু দেখিয়েছে, তারা নির্দিষ্ট ম্যাচে শ্রেষ্ঠ মানুষদের হারাতে পারে। পরে দেখা গেছে, শক্তিশালী Go প্রোগ্রামও কিছু অদ্ভুত কৌশলের বিরুদ্ধে দুর্বল হতে পারে, যা গবেষকেরা adversarial কৌশলে খুঁজে বের করেছেন (প্রকাশিত গবেষণায় এমন উদাহরণ আছে)। মানুষের চেয়ে ভালো খেলা মানে নিখুঁত খেলা নয়।

মূল উপলব্ধি: Go-র বাধা ছিল বড় branching factor আর ভালো evaluation-এর অভাব, যা MCTS আর শেখা network মিলে কাটিয়েছে; আর “সমাধান” একটি গাণিতিক প্রমাণ, “মানুষকে হারানো” একটি পরীক্ষার ফল, দুটি এক নয়।

প্র ০৩Minimax প্রতিপক্ষকে নিখুঁত ধরে নেয়। দুর্বল বা অভ্যাসী প্রতিপক্ষের বিরুদ্ধে এটি কি সবসময় সেরা কৌশল?

Minimax-এর প্রতিশ্রুতি হলো একটি নিশ্চিত নিচের সীমা: প্রতিপক্ষ যা-ই করুক, আপনি অন্তত এতটুকু পাবেন। নিখুঁত প্রতিপক্ষের বিরুদ্ধে এর চেয়ে ভালো কিছু নেই। কিন্তু প্রতিপক্ষ যদি নিখুঁত না হয়, minimax টাকা টেবিলে ফেলে রাখতে পারে।

Tic-tac-toe-ই উদাহরণ। খালি বোর্ডে X-এর ৯টি প্রথম চালের minimax মান 0, তাই minimax-এর কাছে সব সমান। কিন্তু একজন নতুন খেলোয়াড়ের বিরুদ্ধে কোণায় শুরু করলে প্রতিপক্ষের ভুল করার সম্ভাবনা বেশি, কারণ O-র একমাত্র নিরাপদ জবাব মাঝের ঘর; অন্য যেকোনো জবাবে X জোর করে জেতে। লেখকের যন্ত্রে minimax দিয়ে মিলিয়ে দেখা হয়েছে: কোণায় শুরু করলে O-র ৮টি জবাবের ৭টিই হারে, আর মাঝখানে শুরু করলে হারে ৪টি (চারটি ধার), চারটি কোণা নিরাপদ। অংশ ৪-এর ফাঁদের অবস্থাও একই শিক্ষা দেয়, উল্টো দিক থেকে। এ ধরনের পার্থক্য দেখতে হলে প্রতিপক্ষের একটি মডেল লাগে: ধরুন প্রতিপক্ষ ৮০% সময় সেরা চাল দেয়, বাকি সময় এলোমেলো। তখন MIN node আর শুধু min নয়, একটি সম্ভাবনা-ওজনসহ গড়, অর্থাৎ expectiminimax-এর মতো হিসাব (কখনো expectimax বলা হয়)।

কিন্তু প্রতিপক্ষের মডেলের নিজস্ব ঝুঁকি আছে। মডেল ভুল হলে, বা প্রতিপক্ষ বুঝে ফেলে আপনি তার ভুলের ওপর বাজি ধরছেন, তাহলে সে আপনাকে ঠকাতে পারে। পোকারে এটি স্পষ্ট: দুর্বল খেলোয়াড়কে কাজে লাগানোর কৌশল (exploitative) বেশি লাভ দেয়, কিন্তু নিজেও দুর্বল হয়ে যায়; নিরাপদ কৌশল (equilibrium) কম লাভ দেয়, কিন্তু কেউ তাকে সহজে কাজে লাগাতে পারে না। নিরাপত্তায় সাধারণত দ্বিতীয়টি বেছে নেওয়া হয়, কারণ আক্রমণকারীর ভুলের ওপর ভরসা করা বিপজ্জনক।

মূল উপলব্ধি: Minimax worst-case-এর বিরুদ্ধে বিমা; প্রতিপক্ষ সম্পর্কে নির্ভরযোগ্য তথ্য থাকলে মডেলভিত্তিক কৌশল বেশি লাভ দিতে পারে, কিন্তু সেই তথ্য ভুল হলে দামও দিতে হয়।

প্র ০৪তাস বা পোকারের মতো লুকানো তথ্যের খেলায় minimax কেন সরাসরি চলে না? সেখানে AI কী করে?

Minimax-এর প্রতিটি node একটি সুনির্দিষ্ট অবস্থা, যা দুজনেই জানে। তাসের খেলায় আপনি নিজের হাতের তাস জানেন, প্রতিপক্ষেরটা নয়। আপনার চোখে অনেকগুলো আসল অবস্থা একই রকম দেখায়; game theory-তে একে বলে information set. আপনার সিদ্ধান্ত এই পুরো সেটের জন্য একটিই হতে হবে, কারণ আপনি জানেন না কোন অবস্থায় আছেন। ফলে “এই node-এ max নাও” নিয়ম আর কাজ করে না।

আরেকটি সমস্যা হলো, লুকানো তথ্যের খেলায় সেরা কৌশল প্রায়ই এলোমেলো (mixed strategy). পোকারে সবসময় ভালো তাসে বাজি বাড়ালে প্রতিপক্ষ আপনার তাস পড়ে ফেলবে; তাই কখনো কখনো দুর্বল তাসেও বাজি বাড়াতে হয় (bluff). John von Neumann-এর ১৯২৮ সালের minimax theorem আসলে এই এলোমেলো কৌশলের কথাই বলে: দুই খেলোয়াড়ের zero-sum খেলায় এলোমেলো কৌশল নিলে একটি নিশ্চিত মান থাকে। Perfect information-এর খেলায় (Zermelo-র ধারণা অনুযায়ী) এলোমেলো কৌশলের দরকার হয় না, যা এই পর্বের পদ্ধতিগুলোকে সহজ রাখে।

এমন খেলায় আধুনিক AI প্রায়ই counterfactual regret minimization (CFR) নামের পদ্ধতিতে বারবার নিজের সঙ্গে খেলে ধীরে ধীরে equilibrium-এর কাছাকাছি কৌশল শেখে। Carnegie Mellon-এর Libratus (২০১৭) দুই খেলোয়াড়ের no-limit পোকারে শীর্ষ পেশাদারদের বিরুদ্ধে একটি দীর্ঘ প্রতিযোগিতায় জিতেছিল বলে প্রকাশিত হয়েছে, আর Pluribus (২০১৯, Facebook AI-এর সঙ্গে যৌথ) ছয় খেলোয়াড়ের খেলায় ভালো ফল দেখায়। ব্রিজের মতো খেলায় একটি পুরোনো কৌশল হলো লুকানো তাসের সম্ভাব্য বিন্যাস এলোমেলো করে বানিয়ে প্রতিটিকে perfect-information খেলা হিসেবে সমাধান করে গড় নেওয়া, যদিও এর তাত্ত্বিক দুর্বলতা পরিচিত।

মূল উপলব্ধি: লুকানো তথ্য মানে আপনি অবস্থা নয়, অবস্থার একটি সেট জানেন; সেখানে নিশ্চিত চালের বদলে এলোমেলো কৌশল আর ভিন্ন অ্যালগরিদম লাগে।

প্র ০৫খেলায় এত সাফল্যের পর অনেকে বলেন, “খেলা জিতেছে, এবার বাস্তব জগৎ।” খেলার সাফল্য থেকে বাস্তব প্রতিপক্ষ বা বাস্তব সমস্যায় কতটা নেওয়া যায়?

খেলা AI গবেষণার প্রিয় পরীক্ষাগার হয়েছে কারণ এর কয়েকটি বিরল সুবিধা আছে। নিয়ম পুরোপুরি জানা ও স্থির। ফল স্পষ্ট: জয়, হার বা ড্র। লক্ষ লক্ষ খেলা সস্তায় simulate করা যায়, বাস্তবের কোনো ক্ষতি ছাড়াই। আর অগ্রগতি মাপা সহজ: শক্তিশালী মানুষ বা আগের প্রোগ্রামের বিরুদ্ধে খেলুন। Shannon থেকে AlphaGo পর্যন্ত এই সুবিধাগুলো search, evaluation আর learning-এর কৌশল তৈরিতে বিশাল ভূমিকা রেখেছে।

বাস্তব সমস্যায় এই সুবিধাগুলোর প্রায় সবই অনুপস্থিত। নিয়ম অজানা বা বদলায়; ঢাকার রাস্তায় একটি ডেলিভারি পরিকল্পনা বৃষ্টি, যানজট আর মানুষের সিদ্ধান্তে প্রতিদিন বদলায়। লক্ষ্য প্রায়ই একটি সংখ্যা নয়, অনেকগুলো পরস্পরবিরোধী মূল্যবোধ। Simulator থাকলেও তা বাস্তবের হুবহু নয়, আর simulator-এ শেখা কৌশল বাস্তবে ব্যর্থ হতে পারে। ভুলের দাম বাস্তব: একটি খেলা হারলে আরেকটি খেলা যায়, কিন্তু চিকিৎসা বা আর্থিক সিদ্ধান্তে নয়। আর বাস্তব “প্রতিপক্ষ” (প্রতারক, spam-প্রেরক, আক্রমণকারী) নিয়ম মেনে খেলে না; তারা খেলার নিয়মই বদলে দেয়, যেমন spam ফিল্টার শিখলে spam-প্রেরক নতুন কৌশল বের করে।

তবু কিছু ধারণা বাস্তবে খুব কাজের। Worst-case চিন্তা নিরাপত্তার নকশায় অপরিহার্য। Search আর learning-এর মিশ্রণ (planning-এর সঙ্গে শেখা মডেল) রোবোটিক্স আর সময়সূচি তৈরির মতো ক্ষেত্রে গবেষণা হচ্ছে। MCTS-এর ধারণা কিছু planning সমস্যায় ব্যবহার হয়েছে। আর “নিজের কোড জানা উত্তরে যাচাই করো” শিক্ষাটি সব জায়গায় খাটে। কিন্তু খেলায় সাফল্যকে সাধারণ বুদ্ধির প্রমাণ বলা অতিরঞ্জন; AlphaGo Go খেলতে পারে, রান্না বা দরদাম নয়।

মূল উপলব্ধি: খেলা AI-এর কৌশল তৈরির চমৎকার পরীক্ষাগার, কিন্তু বাস্তবে নিয়ম, লক্ষ্য আর প্রতিপক্ষ সবই অনিশ্চিত; ধারণাগুলো নেওয়া যায়, সাফল্যের দাবি নয়।

অনুশীলন

প্রথমটি হাতে trace-এর, দ্বিতীয়টি আউটপুট অনুমানের, তৃতীয়টি নকশা ও সমালোচনার, শেষটি কোড লিখে একটি খেলা সমাধানের।

  1. হাতে trace: তিন ply-এর একটি গাছ: root MAX; তার দুটি MIN সন্তান L আর R; প্রত্যেকের দুটি MAX সন্তান; প্রত্যেক MAX node-এর দুটি leaf. বাঁ থেকে ডানে leaf: L-এর নিচে [4, 6] আর [7, 9]; R-এর নিচে [1, 2] আর [8, 5]. (ক) প্রতিটি node-এর minimax মান আর root-এর সেরা চাল। (খ) বাঁ থেকে ডানে alpha-beta চালালে কোন leaf দেখা হয় না? কয়টি leaf দেখা হয়?

    (ক) MAX স্তর: max(4, 6) = 6, max(7, 9) = 9, max(1, 2) = 2, max(8, 5) = 8. MIN স্তর: L = min(6, 9) = 6, R = min(2, 8) = 2. Root = max(6, 2) = 6, চাল L.

    (খ) L-এর প্রথম MAX সন্তান: 4, 6 দেখে মান 6, তাই L-এ β = 6. L-এর দ্বিতীয় MAX সন্তানে প্রথম leaf 7: এখানে α = 7 ≥ β = 6, তাই 9 কাটা (L কখনো 6-এর বেশি দেবে না)। L = 6, root-এ α = 6. R-এর প্রথম MAX সন্তান: 1, তারপর 2 (এখানে β = +∞, তাই কাটা নেই), মান 2. R-এ β = 2 ≤ α = 6, তাই R-এর দ্বিতীয় MAX সন্তান পুরোটাই কাটা, মানে leaf 8 আর 5. দেখা leaf ৫টি (4, 6, 7, 1, 2), বাদ ৩টি। লেখকের যন্ত্রে অংশ ৬-এর ধাঁচের কোডে মিলিয়ে দেখা: root 6, কাটা অংশ [9] আর [[8, 5]].

  2. আউটপুট অনুমান করুন: চালানোর আগে প্রতিটি লাইনের আউটপুট লিখুন, তারপর যেকোনো ব্লকে বসিয়ে মিলিয়ে দেখুন।
    def mm(t, maxi):
        if isinstance(t, int):
            return t
        vals = [mm(c, not maxi) for c in t]
        return max(vals) if maxi else min(vals)
    
    print(mm([[3, 12], [8, 2], [4, 6]], True))
    print(mm([[3, 12], [8, 2], [4, 6]], False))
    print(mm([[[1, 5], [6, 2]], [[7, 0], [3, 4]]], True))
    print(mm(7, False))
    4
    6
    5
    7

    প্রথম লাইন: root MAX, সন্তানেরা MIN: min(3, 12) = 3, min(8, 2) = 2, min(4, 6) = 4; max = 4. দ্বিতীয়: একই গাছ, কিন্তু root MIN আর সন্তানেরা MAX: 12, 8, 6; min = 6. একই leaf, কে আগে চাল দেয় তাতেই ফল বদলায়। তৃতীয়: তিন স্তর; [1, 5] → 5, [6, 2] → 6, তাদের MIN 5; [7, 0] → 7, [3, 4] → 4, তাদের MIN 4; root max(5, 4) = 5. চতুর্থ: একটি সংখ্যা নিজেই leaf, তাই maxi যা-ই হোক 7. লেখকের যন্ত্রে Python 3.14.4-এ চালিয়ে মেলানো।

  3. নকশা ও সমালোচনা: একটি AI সহকারী tic-tac-toe-এর জন্য নিচের alpha-beta দিয়েছে (বোর্ড একটি list). অন্তত পাঁচটি সমস্যা খুঁজে একটি ভালো সংস্করণের নকশা বলুন।
    def alphabeta(board, depth, alpha, beta, maximizing):
        if depth == 0:
            return evaluate(board)
        best = -999
        for move in legal_moves(board):
            board[move] = "X" if maximizing else "O"
            val = alphabeta(board, depth - 1, alpha, beta, maximizing)
            best = max(best, val)
            if best > beta:
                break
        return best
    • Terminal test নেই: কেউ জিতে গেলেও search চলতে থাকে, আর জেতা অবস্থাকেও evaluate দিয়ে অনুমান করা হয়। আগে terminal হলে utility (বড় ±মান) ফেরত দিতে হবে, তারপর depth-এর সীমা।
    • পালা বদলায় না: recursive ডাকে maximizing একই থাকে; হওয়া উচিত not maximizing.
    • MIN-এর হিসাব নেই: সবসময় max আর −999 থেকে শুরু; MIN node-এ +999 থেকে শুরু করে min লাগবে।
    • বোর্ড ফেরত আসে না: চাল বসানোর পর মোছা হয় না (undo নেই), তাই পরের চালগুলো ভুল বোর্ডে খেলা হয়। হয় board[move] = "." দিয়ে ফেরানো, নয়তো অংশ ৪-এর মতো নতুন string বানানো।
    • α কখনো হালনাগাদ হয় না, আর শর্ত ভুল: MAX node-এ alpha = max(alpha, best), MIN node-এ beta = min(beta, best), আর কাটার শর্ত alpha >= beta.
    • যাচাই নেই: সাধারণ minimax-এর সঙ্গে মান তুলনা বা খালি বোর্ডে 0 আসে কি না, এমন কোনো পরীক্ষা নেই।
  4. কোড লিখে একটি খেলা সমাধান: n টি কাঠির একটি স্তূপ। নিজের পালায় ১, ২ বা ৩টি কাঠি তুলতে হয়; যে শেষ কাঠি তোলে সে জেতে। memoization-সহ search লিখে (ক) n ≤ 20-এর কোন অবস্থাগুলো যার চাল তার জন্য হারার, তা বের করুন, (খ) একটি সরল নিয়ম অনুমান করে কোডেই যাচাই করুন, (গ) ২১টি কাঠিতে সেরা চাল বলুন।
    from functools import lru_cache
    
    @lru_cache(maxsize=None)
    def wins(n):                      # n টি কাঠি, যার চাল সে কি জিততে পারে?
        return any(not wins(n - k) for k in (1, 2, 3) if k <= n)
    
    def best_move(n):
        good = [k for k in (1, 2, 3) if k <= n and not wins(n - k)]
        return good[0] if good else None   # হারার অবস্থায় কোনো জেতার চাল নেই
    
    lose = [n for n in range(21) if not wins(n)]
    print("হারার অবস্থা (n <= 20):", lose)
    print("সব হারার অবস্থা n % 4 == 0?", all(n % 4 == 0 for n in lose))
    print("21 টি কাঠিতে সেরা চাল:", best_move(21), "| 20 টিতে:", best_move(20))

    লেখকের যন্ত্রে সত্যিকারের run (Python 3.14.4):

    হারার অবস্থা (n <= 20): [0, 4, 8, 12, 16, 20]
    সব হারার অবস্থা n % 4 == 0? True
    21 টি কাঠিতে সেরা চাল: 1 | 20 টিতে: None

    নিয়ম: n ৪-এর গুণিতক হলে যার চাল সে হারে, কারণ সে যা-ই তুলুক (১ থেকে ৩), প্রতিপক্ষ বাকিটা তুলে মোট ৪ করে আবার ৪-এর গুণিতকে ফেরাতে পারে। তাই ২১টিতে ১টি তুলে ২০ রেখে দিন। এটি অংশ ৭-এর Nim-এর মতোই: search দিয়ে নিয়ম অনুমান, তারপর নিয়ম দিয়ে search যাচাই।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমMonte Carlo tree search-এর একটি পুনরাবৃত্তির ধাপ আর শেষের সিদ্ধান্ত সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔎 Selection: root থেকে UCB1 দিয়ে সন্তান বেছে নিচে নামা
  2. 🌱 Expansion: এখনো চেষ্টা না করা একটি চালের নতুন node যোগ
  3. 🎲 Simulation: নতুন node থেকে এলোমেলো playout-এ খেলা শেষ
  4. ⬆️ Backup: পথের প্রতিটি node-এ ভিজিট আর জয়ের হিসাব হালনাগাদ
  5. 🏁 বাজেট শেষে সবচেয়ে বেশি ভিজিট হওয়া চাল বাছাই

নিচের কোন খেলাটি “দুই খেলোয়াড়, পালা করে, zero-sum, perfect information আর ভাগ্যহীন” শ্রেণিতে পড়ে না?

  • দাবা
  • Tic-tac-toe
  • পোকার
  • Go

একটি গাছে root MAX, তার তিনটি MIN সন্তান, আর তাদের leaf যথাক্রমে [2, 7], [5, 4] আর [1, 9]. Root-এর minimax মান কত?

  • 9
  • 4
  • 2
  • 1

Branching factor b = 10 আর গভীরতা d = 6-এর একটি সমান গাছ। নিখুঁত move ordering-এ alpha-beta কয়টি leaf দেখে? (সূত্র b⌈d/2⌉ + b⌊d/2⌋ − 1)

  • 1,999
  • 1,000,000
  • 1,000
  • 60

Alpha-beta চলার সময় একটি MIN node-এ বাকি সন্তানদের দেখা কখন বন্ধ করা হয়?

  • প্রথম সন্তান দেখার পরে সবসময়
  • যখন β, α-র চেয়ে বড় হয়ে যায়
  • যখন একটি leaf-এর মান ঋণাত্মক
  • যখন β ≤ α

Nim-এ তিনটি স্তূপ (1, 4, 6), সাধারণ নিয়ম (শেষ কাঠি যে তোলে সে জেতে)। এখন যার চাল, তার জন্য কোনটি সঠিক?

  • nim-sum শূন্য, তাই সে হারবে
  • nim-sum 3; ৬-এর স্তূপ থেকে ১টি কাঠি তুলে জেতার পথে থাকবে
  • nim-sum 3; ৬-এর স্তূপ থেকে ৩টি তুলবে
  • nim-sum 3; ১-এর স্তূপটি খালি করবে

একটি দাবার প্রোগ্রাম ৬ ply গভীরে দেখে। ৮ ply পরে তার মন্ত্রী হারানো অনিবার্য, তাই সে একটি বোড়ে বিসর্জন দিয়ে ক্ষতিটি সীমার বাইরে ঠেলে দেয়। এই সমস্যার নাম আর একটি প্রচলিত প্রতিকার কী?

  • Overfitting; বেশি ডেটা
  • Alpha-beta-র ভুল; pruning বন্ধ করা
  • Horizon effect; quiescence search
  • Combinatorial explosion; memoization

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. খেলা = প্রতিপক্ষসহ search: initial state, player, actions, result, terminal test, utility (MAX-এর চোখে)। এই পর্বের পদ্ধতি দুই খেলোয়াড়ের, পালা করা, zero-sum, perfect information খেলার জন্য।
  2. Minimax: MAX বড়টি, MIN ছোটটি, leaf থেকে মান ওপরে। Tic-tac-toe: ২,৫৫,১৬৮টি খেলা, ৫,৪৯,৯৪৬টি node, ৫,৪৭৮টি অবস্থা; memoization দিয়ে প্রমাণ, নিখুঁত খেলায় ড্র।
  3. Alpha-beta: α = MAX-এর নিশ্চিত সেরা, β = MIN-এর; α ≥ β হলে কাটা। উত্তর একই, খরচ কম; নিখুঁত ক্রমে প্রায় O(bm/2), তাই চালের ক্রম গুরুত্বপূর্ণ।
  4. বড় খেলায় গভীরতার সীমা + evaluation function; সীমার ওপারে horizon effect. দানের খেলায় chance node-এর মান expected value; সেখানে মানের মাপও গুরুত্বপূর্ণ।
  5. যাচাই: জানা উত্তরে (Nim-এর XOR, ড্র, Knuth-Moore) কোড মেলান। MCTS playout আর UCB1 দিয়ে গাছ গড়ে; flat playout ফাঁদে পড়তে পারে। Deep Blue ছিল দ্রুত alpha-beta, AlphaGo MCTS + শেখা network.

পরের পর্বে: প্রতিপক্ষ নেই, কিন্তু অনেক শর্ত আছে: সাপ্তাহিক রুটিন, পরীক্ষার সময়সূচি, Sudoku. Constraint satisfaction আর backtracking দিয়ে এমন সমস্যা কীভাবে সমাধান হয়।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান