""" moysiaanse_fit_test_v2.py ========================== Empirische toets van het Moysiaanse-fit-effect bij misfit-deals. Filters (allemaal nodig): 1. Beide handen moeten een singleton of renonce hebben. (Zonder kortheid bij beide loopt het bieden naar SA.) 2. NZ mag geen 8+-fit hebben. 3. Totaaldistributie moet 7-7-6-6 of 7-7-7-5 zijn. Vier speelsoort-categorieën worden onderzocht: - SA - Moysiaans 4-3 fit (split 4-3 of 3-4) - 5-2 fit (split 5-2 of 2-5) — biedtechnisch toegankelijk via b.v. 1S-1SA-2K-2S; vaak het resultaat van een natuurlijk biedverloop zonder bewuste keuze tussen SA en troef. - Lange-kant fit (6-1, 7-0) Binnen elke totaaldistributie wordt de data uitgesplitst per handpatroon-combinatie. Vereist: endplay (pip install endplay) """ from endplay.dealer import generate_deal from endplay.dds import calc_dd_table from endplay.types import Player, Denom import multiprocessing as mp from collections import Counter, defaultdict import random import statistics import time import sys SUITS = [Denom.spades, Denom.hearts, Denom.diamonds, Denom.clubs] SUIT_LETTER = {Denom.spades: 'S', Denom.hearts: 'H', Denom.diamonds: 'D', Denom.clubs: 'C'} def hcp_in_suit(hand, denom): s = str(hand[denom]) return s.count('A') * 4 + s.count('K') * 3 + s.count('Q') * 2 + s.count('J') * 1 def hand_lengths(hand): return {d: len(hand[d]) for d in SUITS} def detect_misfit(north, south): """ Bepaal of deze deal een gekwalificeerde misfit is. Returns: (sorted_fits, pat_combo) of None. """ n_lens = hand_lengths(north) s_lens = hand_lengths(south) # Filter 1: beide handen singleton/renonce if min(n_lens.values()) > 1: return None if min(s_lens.values()) > 1: return None # Filter 2: geen 8+-fit voor NZ fits = [n_lens[d] + s_lens[d] for d in SUITS] if max(fits) >= 8: return None # Filter 3: totaaldistributie 7-7-6-6 of 7-7-7-5 sorted_fits = tuple(sorted(fits, reverse=True)) if sorted_fits not in [(7, 7, 6, 6), (7, 7, 7, 5)]: return None # Categoriseer per handpatroon-combinatie (N en S symmetrisch) n_pat = tuple(sorted(n_lens.values(), reverse=True)) s_pat = tuple(sorted(s_lens.values(), reverse=True)) pat_combo = tuple(sorted([n_pat, s_pat], reverse=True)) return (sorted_fits, pat_combo) def aces_in_suit(hand, denom): return str(hand[denom]).count('A') def analyze_one_deal(seed): random.seed(seed) deal = generate_deal() north = deal.north south = deal.south result = detect_misfit(north, south) if result is None: return None sorted_fits, pat_combo = result try: dd_table = calc_dd_table(deal) except Exception: return None tricks_sa = (dd_table[Denom.nt, Player.north] + dd_table[Denom.nt, Player.south]) / 2.0 tricks_suit = {SUIT_LETTER[d]: (dd_table[d, Player.north] + dd_table[d, Player.south]) / 2.0 for d in SUITS} n_lens = hand_lengths(north) s_lens = hand_lengths(south) fits = {SUIT_LETTER[d]: n_lens[d] + s_lens[d] for d in SUITS} n_lens_s = {SUIT_LETTER[d]: n_lens[d] for d in SUITS} s_lens_s = {SUIT_LETTER[d]: s_lens[d] for d in SUITS} suit_hcp = {SUIT_LETTER[d]: hcp_in_suit(north, d) + hcp_in_suit(south, d) for d in SUITS} # Shell-grootte en werkende HCP (strenge definitie, zoals shell_rule_test.py) # Een kleur is uitgeschakeld als minstens één van N of S daar 0-1 heeft. uitgeschakeld = set() aces_per_suit = {} for d in SUITS: s = SUIT_LETTER[d] aces_per_suit[s] = aces_in_suit(north, d) + aces_in_suit(south, d) if n_lens[d] <= 1 or s_lens[d] <= 1: uitgeschakeld.add(s) shell_grootte = 40 - 10 * len(uitgeschakeld) # Werkende HCP: HCP in actieve kleuren + alle azen (4 HCP per aas) werkende_hcp = 0 for s in 'SHDC': if s in uitgeschakeld: werkende_hcp += aces_per_suit[s] * 4 # alleen azen else: werkende_hcp += suit_hcp[s] # alle HCP return { 'sorted_fits': sorted_fits, 'pat_combo': pat_combo, 'tricks_sa': tricks_sa, 'tricks_suit': tricks_suit, 'fits': fits, 'n_lens': n_lens_s, 's_lens': s_lens_s, 'suit_hcp': suit_hcp, 'ns_hcp': sum(suit_hcp.values()), 'shell_grootte': shell_grootte, 'werkende_hcp': werkende_hcp, 'n_uitgeschakeld': len(uitgeschakeld), } def worker_chunk(seeds): out = [] for seed in seeds: r = analyze_one_deal(seed) if r is not None: out.append(r) return out def format_pat(pat): """Format een patroon-tuple (5,4,3,1) als '5-4-3-1'.""" return '-'.join(str(x) for x in pat) def format_combo(combo): """Format combo ((5,4,3,1), (5,4,3,1)) als '5-4-3-1 vs 5-4-3-1'.""" return f"{format_pat(combo[0])} vs {format_pat(combo[1])}" def best_speelsoort_per_deal(r): """ Per deal: identificeer wat SA, beste Moysiaanse 4-3, beste 5-2 fit en beste lange-kant-fit oplevert, en welke speelsoort wint. Vier categorieën 7-fit: - Moysiaans: 4-3 of 3-4 - 5-2 fit: 5-2 of 2-5 (toegankelijk via natuurlijk biedverloop) - Lange-kant: 6-1, 7-0 of omgekeerd """ sa = r['tricks_sa'] # Moysiaanse 4-3 fits moy_options = [(s, r['tricks_suit'][s]) for s in 'SHDC' if r['fits'][s] == 7 and r['n_lens'][s] in (3, 4) and r['s_lens'][s] in (3, 4)] # 5-2 fits (vaak het resultaat van een biedverloop als 1S-1SA-2K-2S) fivetwo_options = [(s, r['tricks_suit'][s]) for s in 'SHDC' if r['fits'][s] == 7 and {r['n_lens'][s], r['s_lens'][s]} == {5, 2}] # Lange-kant fits (6-1, 7-0) longside_options = [(s, r['tricks_suit'][s]) for s in 'SHDC' if r['fits'][s] == 7 and (r['n_lens'][s] >= 6 or r['s_lens'][s] >= 6)] best_moy = max(moy_options, key=lambda x: x[1]) if moy_options else None best_52 = max(fivetwo_options, key=lambda x: x[1]) if fivetwo_options else None best_longside = max(longside_options, key=lambda x: x[1]) if longside_options else None options = [('SA', sa)] if best_moy: options.append(('Moysiaans 4-3', best_moy[1])) if best_52: options.append(('5-2 fit', best_52[1])) if best_longside: options.append(('Lange-kant 6-1/7-0', best_longside[1])) best = max(options, key=lambda x: x[1]) return { 'sa': sa, 'best_moy': best_moy[1] if best_moy else None, 'best_52': best_52[1] if best_52 else None, 'best_longside': best_longside[1] if best_longside else None, 'winning_kind': best[0], 'winning_tricks': best[1], } def report_combo(combo, results): """Rapport voor één (totaaldistributie, patroon-combinatie).""" n = len(results) if n == 0: return avg_hcp = statistics.mean(r['ns_hcp'] for r in results) avg_sa = statistics.mean(r['tricks_sa'] for r in results) # Verzamel speelsoort-uitkomsten moy_tricks = [] fivetwo_tricks = [] longside_tricks = [] winner_counter = Counter() diff_moy_sa = [] diff_52_sa = [] diff_long_sa = [] for r in results: s = best_speelsoort_per_deal(r) winner_counter[s['winning_kind']] += 1 if s['best_moy'] is not None: moy_tricks.append(s['best_moy']) diff_moy_sa.append(s['best_moy'] - s['sa']) if s['best_52'] is not None: fivetwo_tricks.append(s['best_52']) diff_52_sa.append(s['best_52'] - s['sa']) if s['best_longside'] is not None: longside_tricks.append(s['best_longside']) diff_long_sa.append(s['best_longside'] - s['sa']) print(f"\n {format_combo(combo)} n={n}, gem. HCP={avg_hcp:.1f}") print(f" Gem. slagen SA : {avg_sa:.2f}") if moy_tricks: print(f" Gem. slagen beste Moy 4-3 : {statistics.mean(moy_tricks):.2f} " f"(verschil met SA: {statistics.mean(diff_moy_sa):+.2f})") wins = sum(1 for d in diff_moy_sa if d > 0) print(f" Moy wint van SA : {wins}/{len(diff_moy_sa)} " f"({100*wins/len(diff_moy_sa):.1f}%)") if fivetwo_tricks: print(f" Gem. slagen beste 5-2 fit : {statistics.mean(fivetwo_tricks):.2f} " f"(verschil met SA: {statistics.mean(diff_52_sa):+.2f})") wins = sum(1 for d in diff_52_sa if d > 0) print(f" 5-2 wint van SA : {wins}/{len(diff_52_sa)} " f"({100*wins/len(diff_52_sa):.1f}%)") if longside_tricks: print(f" Gem. slagen beste lange-kant: {statistics.mean(longside_tricks):.2f} " f"(verschil met SA: {statistics.mean(diff_long_sa):+.2f})") wins = sum(1 for d in diff_long_sa if d > 0) print(f" Lange-kant wint van SA : {wins}/{len(diff_long_sa)} " f"({100*wins/len(diff_long_sa):.1f}%)") print(f" Beste speelsoort verdeling :") total = sum(winner_counter.values()) for kind, count in winner_counter.most_common(): print(f" {kind:<25} : {count:>5} ({100*count/total:>5.1f}%)") def report_totaaldistributie(td_label, by_combo): """Rapport voor één totaaldistributie.""" all_results = [r for combo_results in by_combo.values() for r in combo_results] n_total = len(all_results) print(f"\n{'='*72}") print(f"=== Totaaldistributie {td_label}: {n_total} matches ===") print(f"{'='*72}") if n_total == 0: return # Aggregaat over alle patroon-combinaties avg_hcp = statistics.mean(r['ns_hcp'] for r in all_results) avg_sa = statistics.mean(r['tricks_sa'] for r in all_results) winner_counter = Counter() diff_moy_sa = [] diff_52_sa = [] diff_long_sa = [] for r in all_results: s = best_speelsoort_per_deal(r) winner_counter[s['winning_kind']] += 1 if s['best_moy'] is not None: diff_moy_sa.append(s['best_moy'] - s['sa']) if s['best_52'] is not None: diff_52_sa.append(s['best_52'] - s['sa']) if s['best_longside'] is not None: diff_long_sa.append(s['best_longside'] - s['sa']) print(f"\nAggregaat over alle patroon-combinaties:") print(f" Gem. NS HCP : {avg_hcp:.2f}") print(f" Gem. slagen SA : {avg_sa:.2f}") if diff_moy_sa: wins_m = sum(1 for d in diff_moy_sa if d > 0) print(f" Moy 4-3 vs SA : " f"{statistics.mean(diff_moy_sa):+.2f} slag gem., " f"wint {100*wins_m/len(diff_moy_sa):.1f}% (n={len(diff_moy_sa)})") if diff_52_sa: wins_52 = sum(1 for d in diff_52_sa if d > 0) print(f" 5-2 fit vs SA : " f"{statistics.mean(diff_52_sa):+.2f} slag gem., " f"wint {100*wins_52/len(diff_52_sa):.1f}% (n={len(diff_52_sa)})") if diff_long_sa: wins_l = sum(1 for d in diff_long_sa if d > 0) print(f" Lange-kant vs SA : " f"{statistics.mean(diff_long_sa):+.2f} slag gem., " f"wint {100*wins_l/len(diff_long_sa):.1f}% (n={len(diff_long_sa)})") print(f" Beste speelsoort :") total = sum(winner_counter.values()) for kind, count in winner_counter.most_common(): print(f" {kind:<25} : {count:>5} ({100*count/total:>5.1f}%)") # Per patroon-combinatie, gesorteerd op frequentie (aflopend) print(f"\nUitsplitsing per handpatroon-combinatie:") sorted_combos = sorted(by_combo.items(), key=lambda x: -len(x[1])) for combo, results in sorted_combos: if len(results) < 20: # Te weinig data, alleen het aantal melden print(f"\n {format_combo(combo)} n={len(results)} " f"(te weinig voor aparte aggregatie)") continue report_combo(combo, results) def report_shell_tabel(results): """ Aggregeer per (Shell-grootte, werkende HCP) de gemiddelde slagen in de beste speelsoort. Vergelijkbaar met Bijlage G van het boek, maar specifiek voor misfit-deals (geen 8+-fit). """ print(f"\n{'='*72}") print("SHELL-TABEL voor misfit-deals (geen 8+-fit)") print(f"{'='*72}") print("\nVoor elke deal: bepaal Shell-grootte (40 - 10 per uitgeschakelde") print("zijkleur) en werkende HCP (HCP in actieve kleuren + alle azen).") print("Sla de beste speelsoort op (SA / Moysiaans / 5-2 / lange-kant).") print("Aggregeer per (Shell-grootte, werkende HCP).\n") # Bepaal voor elke deal de beste speelsoort-slagen enriched = [] for r in results: s = best_speelsoort_per_deal(r) enriched.append({ 'shell_grootte': r['shell_grootte'], 'werkende_hcp': r['werkende_hcp'], 'beste_tricks': s['winning_tricks'], 'beste_kind': s['winning_kind'], 'tricks_sa': r['tricks_sa'], 'ns_hcp': r['ns_hcp'], 'sorted_fits': r['sorted_fits'], }) # Tel deals per Shell-grootte shell_counts = Counter(e['shell_grootte'] for e in enriched) print("Verdeling van deals over Shell-groottes:") for sg in sorted(shell_counts.keys(), reverse=True): n = shell_counts[sg] pct = 100 * n / len(enriched) print(f" Shell {sg:<3} : {n:>5} deals ({pct:>5.1f}%)") # Voor elke Shell-grootte: tabel werkende HCP -> gem. slagen for sg in sorted(shell_counts.keys(), reverse=True): deals = [e for e in enriched if e['shell_grootte'] == sg] if len(deals) < 50: print(f"\n--- Shell {sg}: {len(deals)} deals, te weinig voor stabiele tabel ---") continue print(f"\n{'-'*72}") print(f"--- Shell-grootte {sg} (n={len(deals)}) ---") print(f"{'-'*72}") # Groepeer per werkende HCP by_hcp = defaultdict(list) for e in deals: by_hcp[e['werkende_hcp']].append(e) # Vergelijking met Bijlage G voor referentie print(f"\n{'Werk.HCP':<10}{'n':<7}{'Gem.SA':<10}{'Gem.Beste':<12}" f"{'Verschil':<11}{'%Troef wint':<14}") for hcp in sorted(by_hcp.keys()): group = by_hcp[hcp] if len(group) < 10: continue n = len(group) avg_sa = statistics.mean(e['tricks_sa'] for e in group) avg_best = statistics.mean(e['beste_tricks'] for e in group) wins_troef = sum(1 for e in group if e['beste_kind'] != 'SA') print(f"{hcp:<10}{n:<7}{avg_sa:<10.2f}{avg_best:<12.2f}" f"{avg_best - avg_sa:<+11.2f}{100*wins_troef/n:<14.1f}") # Beste-speelsoort verdeling voor deze Shell-grootte print(f"\nBeste speelsoort verdeling binnen Shell {sg}:") kind_counter = Counter(e['beste_kind'] for e in deals) for kind, count in kind_counter.most_common(): print(f" {kind:<25} : {count:>5} ({100*count/len(deals):>5.1f}%)") # Vergelijking met de Shell-regel uit Bijlage G print(f"\n{'-'*72}") print("Vergelijking met Shell-regel uit Bijlage G") print(f"{'-'*72}") print("Bijlage G zegt (voor 10 slagen / manche):") print(" Shell 40: 25-26 werkende HCP") print(" Shell 30: 21 werkende HCP") print(" Shell 20: 16-17 werkende HCP") print() print("In deze misfit-dataset (geen 8+-fit) ligt de drempel voor de manche") print("vermoedelijk anders, omdat een lange kleur niet beschikbaar is") print("voor lengteslag-ontwikkeling. Kijk in de tabel hierboven waar") print("'Gem.Beste' de 10-slagen-grens passeert.") def main(n_deals=500000, n_workers=8): print(f"Genereer {n_deals:,} deals met {n_workers} workers...") print("Filters:") print(" 1. Beide handen singleton/renonce") print(" 2. NZ heeft geen 8+-fit") print(" 3. Totaaldistributie 7-7-6-6 of 7-7-7-5\n") seeds = list(range(n_deals)) chunk_size = max(1, n_deals // (n_workers * 4)) chunks = [seeds[i:i + chunk_size] for i in range(0, len(seeds), chunk_size)] t0 = time.time() with mp.Pool(n_workers) as pool: all_chunk_results = pool.map(worker_chunk, chunks) results = [r for chunk in all_chunk_results for r in chunk] elapsed = time.time() - t0 print(f"Klaar in {elapsed:.1f}s.") print(f"{len(results):,} matches uit {n_deals:,} deals " f"({100*len(results)/n_deals:.3f}%).") # Groeperen per (totaaldistributie, patroon-combinatie) by_td = defaultdict(lambda: defaultdict(list)) for r in results: by_td[r['sorted_fits']][r['pat_combo']].append(r) for td in [(7, 7, 6, 6), (7, 7, 7, 5)]: label = '-'.join(str(x) for x in td) report_totaaldistributie(label, by_td[td]) # Nieuwe sectie: Shell-tabel voor misfit-deals report_shell_tabel(results) print(f"\n{'='*72}") print("INTERPRETATIE") print(f"{'='*72}") print("Per totaaldistributie en patroon-combinatie staan hierboven:") print(" - Gem. slagen SA, Moysiaans 4-3, 5-2 fit, lange-kant 6-1/7-0") print(" - Welke speelsoort wint hoe vaak") print(" - Slagverschil tussen troef-opties en SA") print() print("De 5-2 fit verdient bijzondere aandacht omdat zij vaak vanzelf") print("ontstaat in een natuurlijk biedverloop (bijv. 1S-1SA-2K-2S),") print("zonder dat de speler bewust kiest tussen SA en troef.") print() print("Hypothese: bij echte misfit (beide handen kort + geen 8-fit)") print("is troef structureel beter dan SA.") if __name__ == '__main__': N_DEALS = 500_000 N_WORKERS = 8 if len(sys.argv) > 1: N_DEALS = int(sys.argv[1]) if len(sys.argv) > 2: N_WORKERS = int(sys.argv[2]) main(n_deals=N_DEALS, n_workers=N_WORKERS)