A/B testing และกับดัก
ประมาณ 35 นาที
เป้าหมายของบท
- ทดสอบความต่างของ purchase rate สองกลุ่มด้วย two-proportion z-test (และ chi-square เป็นทางเลือก)
- เช็ค sample ratio mismatch (SRM) ก่อนเชื่อผลการทดลองใดๆ
- อ่าน funnel ทีละขั้นแทนที่จะดูแค่ metric สุดท้าย
- รู้จักกับดักการหยุดทดลองเร็วเกินไป (ตัวอย่างไม่พอ)
จาก hypothesis test สู่ A/B test
บทที่แล้วเรียนเรื่อง hypothesis test และ p-value ไปแล้ว บทนี้เอามาใช้กับสถานการณ์ที่ DA เจอบ่อยที่สุดอย่างหนึ่ง คือ A/B testing — ทดลองเปลี่ยนอะไรบางอย่าง (หน้าเว็บ, ปุ่ม, flow การจ่ายเงิน) กับผู้ใช้กลุ่มหนึ่ง (variant B) แล้วเทียบกับกลุ่มที่ไม่เปลี่ยน (variant A) ว่าตัวชี้วัดที่สนใจต่างกัน “จริง” หรือแค่ความบังเอิญจากการสุ่ม
ตัวอย่างในบทนี้คือการทดลองหน้า checkout เวอร์ชันใหม่ ข้อมูลอยู่ใน events.csv ซึ่งมีคอลัมน์ variant
(A/B/ว่าง) บอกว่า session นั้นถูกสุ่มเข้ากลุ่มไหน (session ที่ variant เป็นค่าว่างคือ session ก่อนเริ่มทดลอง ไม่นับ)
ก่อนเปรียบเทียบอะไร ต้องสร้าง “ตารางระดับ session” ก่อนเสมอ: กรองเฉพาะ event ที่มี variant, จัดกลุ่มตาม
session_id + variant, แล้วเช็คว่า session นั้นมี event purchase เกิดขึ้นหรือไม่ (ซื้อ/ไม่ซื้อ ต่อ 1 session):
import pandas as pd
events = pd.read_csv("data/events.csv")
ev = events[events['variant'].notna()]
sessions = (
ev.groupby(['session_id', 'variant'])['event_type']
.apply(lambda s: (s == 'purchase').any())
.rename('purchased')
.reset_index()
)
จากตารางนี้จะได้ nA/nB (จำนวน session ของแต่ละ variant) และ bA/bB (จำนวน session ที่ซื้อ) —
ทุกอย่างในบทนี้เริ่มจากตารางนี้ทั้งนั้น
Two-proportion z-test
purchase rate ของ variant A คือ pA = bA / nA และของ B คือ pB = bB / nB — ในข้อมูลชุดนี้ pA ≈ 7.15%
และ pB ≈ 8.76% ดูเผินๆ เหมือน B ดีกว่า แต่ purchase rate ที่มาจากการสุ่มตัวอย่าง แทบไม่มีทางเท่ากันเป๊ะอยู่แล้ว
แม้ของจริงจะไม่ต่างกันเลยก็ตาม (เหมือนโยนเหรียญสองกองแล้วได้หัวไม่เท่ากันพอดี) คำถามที่ต้องตอบคือ
“ความต่างนี้ใหญ่กว่าที่สุ่มมาบังเอิญได้แค่ไหน” — นี่คือหน้าที่ของ two-proportion z-test
สูตร: สมมติก่อนว่า A กับ B ไม่ต่างกันจริง (สมมติฐานว่าง) หา pooled proportion (สัดส่วนรวมถ้าไม่แยกกลุ่ม)
p_pool = (bA + bB) / (nA + nB)
แล้วหา standard error ของผลต่าง และสถิติทดสอบ z:
import numpy as np
se = np.sqrt(p_pool * (1 - p_pool) * (1 / nA + 1 / nB))
z_stat = (pB - pA) / se
z_stat ที่ได้คือ 3.773 — มากกว่าเกณฑ์ 1.96 (ที่ α=0.05) ชัดเจน แปลว่าความต่างนี้ไม่น่าจะเป็นความบังเอิญ
หา p-value สองด้าน (two-sided) ต่อได้จาก scipy.stats.norm:
import scipy.stats as stats
p_value = stats.norm.sf(abs(z_stat)) * 2
ได้ p_value ≈ 0.000161 — น้อยกว่า 0.05 มาก สรุปได้ว่า variant B ทำ purchase rate ได้ดีกว่า A อย่างมีนัยสำคัญ
คิดเป็น relative uplift (pB - pA) / pA * 100 ≈ 22.58%
Chi-square test — อีกเส้นทางที่ไปถึงคำตอบเดียวกัน
ข้อมูลแบบ “ซื้อ/ไม่ซื้อ” คูณ “A/B” จัดเป็นตาราง contingency 2×2 ได้ และทดสอบด้วย chi-square test ได้เช่นกัน:
table = [[nA - bA, bA], [nB - bB, bB]] # แถว = variant, คอลัมน์ = [ไม่ซื้อ, ซื้อ]
chi2_stat, p_chi, dof, expected = stats.chi2_contingency(table)
ได้ chi2_stat ≈ 14.018, p_chi ≈ 0.000181, dof = 1 — สังเกตว่า z_stat² = 3.773² ≈ 14.24 ใกล้เคียงกับ
chi2_stat มาก (ต่างกันเล็กน้อยเพราะ chi-square มี continuity correction) ทั้งสองวิธีทดสอบสมมติฐานเดียวกัน
ด้วยคณิตศาสตร์คนละมุม แล้วให้ข้อสรุปตรงกัน — เป็นการเช็คไขว้ (cross-check) ที่ดี ถ้าสองวิธีขัดแย้งกันแรงๆ
ควรสงสัยว่ามีอะไรผิดพลาดในการคำนวณ
เช็ค sample ratio mismatch (SRM) ก่อนเชื่อผลอะไรทั้งนั้น
ก่อนจะเชื่อ p-value ข้างบน ต้องเช็คสิ่งที่มักถูกมองข้ามก่อนเสมอ คือ sample ratio mismatch (SRM) — การสุ่มผู้ใช้เข้ากลุ่ม A/B ควรได้สัดส่วนตามที่ตั้งใจ (ปกติคือ 50/50) ถ้าระบบสุ่ม/randomization มีบั๊ก (เช่น cache, redirect, หรือ bot บาง segment ตกไปกลุ่มใดกลุ่มหนึ่งเยอะผิดปกติ) สัดส่วนจะเบี้ยว เช่น 46/54
sample_ratio = nA / (nA + nB)
ในข้อมูลชุดนี้ sample_ratio ≈ 0.5001 ใกล้ 50/50 มาก แปลว่า ไม่มี SRM การสุ่มทำงานตามที่ตั้งใจ ผล
การทดสอบข้างบนจึงเชื่อได้ แต่ถ้าเจอสัดส่วนที่เบี้ยวไปมาก (เช่น 0.46) ต้องหยุดและตรวจสอบระบบสุ่มก่อน
ไม่ว่า p-value จะสวยแค่ไหนก็ตาม เพราะ SRM แปลว่ากลุ่มสองกลุ่มอาจมีองค์ประกอบผู้ใช้ต่างกันไปตั้งแต่แรก
แล้วผลต่างที่เห็นอาจไม่ได้มาจากตัวแปรที่ทดลองเลยก็ได้
อ่าน funnel ทีละขั้น ไม่ใช่แค่ metric สุดท้าย
purchase rate เป็นแค่ปลายทางของ funnel (visit → view_product → add_to_cart → checkout → purchase)
การดูแค่ตัวเลขสุดท้ายอาจพลาดเรื่องที่น่าสนใจกว่า ลองนับจำนวน session (unique) ที่ไปถึงแต่ละขั้นของแต่ละ variant:
| ขั้น | variant A | variant B |
|---|---|---|
| visit | 8001 | 7999 |
| view_product | 4781 | 4776 |
| add_to_cart | 1671 | 1635 |
| checkout | 920 | 892 |
| purchase | 572 | 701 |
สังเกตว่า variant B มี session ที่ไปถึง checkout น้อยกว่า A (892 vs 920) แต่กลับมี session ที่ไปถึง
purchase มากกว่า (701 vs 572) แปลว่าเมื่อคิดเป็นอัตราการปิดการขายต่อ session ที่ถึง checkout แล้ว
(purchase / checkout) หน้า checkout เวอร์ชันใหม่ปิดการขายได้ดีกว่าเดิมชัดเจน — insight แบบนี้จะไม่เห็นเลย
ถ้าดูแค่ purchase rate โดยรวม เพราะมันไม่บอกว่าความต่างเกิดที่ขั้นไหนของ funnel
กับดัก: หยุดทดลองเร็วเกินไป
กับดักที่พบบ่อยที่สุดอย่างหนึ่งของ A/B test คือการหยุดทดลองก่อนกำหนดเพราะใจร้อน ลองสมมติว่าทีมเช็คผล เป็นระยะระหว่างทดลอง (เรียง session ตามเวลาที่เข้ามาจริง แล้วดูผล ณ จุดสะสมต่างๆ):
| สัดส่วนของ session ที่ผ่านไปแล้ว | จำนวน session สะสม | p-value ณ จุดนั้น |
|---|---|---|
| 10% | 1,600 | 0.3722 |
| 20% | 3,200 | 0.8081 |
| 30% | 4,800 | 0.8588 |
| 40% | 6,400 | 0.0692 |
| 50% | 8,000 | 0.0052 |
| 60% | 9,600 | 0.0026 |
| 100% | 16,000 | 0.0002 |
ถ้าทีมหยุดทดลองตอน 40% ของ session (p-value = 0.0692 > 0.05) จะสรุปว่า “ยังไม่มีนัยสำคัญ” และอาจตัดสินใจ ยกเลิกการเปลี่ยนแปลงที่จริงๆ แล้วได้ผลดี (ผลตอนครบ 100% คือ p = 0.000161 ซึ่งมีนัยสำคัญชัดเจน) — นี่คือกับดัก หยุดเร็วเกินไปเพราะตัวอย่างยังน้อยไป (statistical power ไม่พอ) ไม่ใช่ว่าผลจริงเปลี่ยนทิศไปมา (ในข้อมูลชุดนี้ เมื่อ p-value ลดต่ำกว่า 0.05 แล้วมันลดลงเรื่อยๆ ไม่กลับไปสูงอีก)
อีกกับดักที่เกี่ยวข้องกัน (คนละแบบ) คือการ peek แล้วหยุดทันทีที่เห็น p < 0.05 ครั้งแรก — ยิ่งเช็คผลบ่อย ระหว่างทดลองเท่าไหร่ ยิ่งมีโอกาสเจอ p < 0.05 จาก “ความบังเอิญชั่วขณะ” มากขึ้นเรื่อยๆ (false positive inflation) ทางแก้ของทั้งสองกับดักคือ กำหนดขนาดตัวอย่างที่ต้องการล่วงหน้า (power analysis) ก่อนเริ่มทดลอง แล้วรอจนครบ หรือถ้าจำเป็นต้องดูผลระหว่างทางจริงๆ ให้ใช้วิธี sequential testing ที่ออกแบบมาสำหรับกรณีนี้โดยเฉพาะ
ตัวอย่างแก้โจทย์
กด "ขั้นต่อไป" ทีละขั้น ลองรันและแก้โค้ดในแต่ละขั้นได้
ทดสอบ purchase rate ระหว่าง variant A กับ B ด้วย two-proportion z-test (ทำมือ)
ทีมโปรดักต์ทดลองหน้า checkout เวอร์ชันใหม่ (variant B) เทียบกับของเดิม (variant A) อยากรู้ว่า purchase rate ของ B ที่สูงกว่า A เป็นความต่างจริง หรือแค่ความบังเอิญจากการสุ่มตัวอย่าง
สร้างตารางระดับ session ก่อนเสมอ: กรองเฉพาะ event ที่มี variant (คือผู้เข้าทดลองจริง) จัดกลุ่มตาม session_id + variant แล้วเช็คว่า session นั้นมี event
purchaseเกิดขึ้นหรือไม่ ได้ nA/bA ของ A และ nB/bB ของ B (n = จำนวน session ทั้งหมด, b = จำนวนที่ซื้อ) แล้วคำนวณ purchase rate pA, pB
ยืนยันด้วยวิธีอื่น — chi-square test บนตาราง 2×2
ผลจาก two-proportion z-test บอกว่าต่างกันอย่างมีนัยสำคัญ ลองตรวจสอบด้วยวิธีอื่นคือ chi-square test บนตาราง contingency 2×2 (แถว = variant, คอลัมน์ = ไม่ซื้อ/ซื้อ) ดูว่าได้ข้อสรุปตรงกันไหม
สร้างตาราง contingency 2×2 จากจำนวน session ที่ไม่ซื้อ/ซื้อของแต่ละ variant
เช็ค sample ratio mismatch (SRM) ก่อนเชื่อผล
ก่อนเชื่อผลข้างบน ควรเช็คก่อนว่าการสุ่มแบ่งผู้เข้าทดลองสอง variant ได้สัดส่วนตามที่ตั้งใจไหม (ปกติควร ใกล้ 50/50) ถ้าเบี้ยวไปมาก (เช่น 46/54) แปลว่าการสุ่มเข้ากลุ่มอาจมีบั๊ก และผลการทดลองทั้งหมดเชื่อไม่ได้ เลยไม่ว่า p-value จะสวยแค่ไหน
นับจำนวน session ของแต่ละ variant แล้วหาสัดส่วนของ A ต่อทั้งหมด (sample ratio)
เติมช่องว่าง
แทน ___ ด้วยโค้ดที่ถูกต้อง แล้วกดตรวจคำตอบ
คำนวณ p_pool (สัดส่วนรวม สมมติว่า variant A กับ B ไม่ต่างกันจริง) เก็บไว้ในตัวแปร result
เติม ___ ให้ครบ
คำนวณ sample ratio ของ variant A (สำหรับเช็ค SRM) เก็บไว้ในตัวแปร result เติม ___ ให้ครบ
แบบฝึกท้ายบท
บทนี้จะนับว่าเสร็จเมื่อผ่านข้อพื้นฐานและประยุกต์ครบ ข้อท้าทายได้ดาวเพิ่ม
🟢 พื้นฐาน
จากตารางระดับ session (กรองเฉพาะ event ที่มี variant แล้วเช็คว่าซื้อหรือไม่ต่อ session) หา purchase rate ของ variant A เก็บไว้ในตัวแปร pA
เหมือน e1 แต่เปลี่ยนเป็น variant B หา purchase rate เก็บไว้ในตัวแปร pB
หา relative uplift ของ B เทียบกับ A เป็นเปอร์เซ็นต์ ((pB - pA) / pA * 100) เก็บไว้ในตัวแปร uplift_pct
คำนวณ z-statistic ของ two-proportion z-test (หา p_pool, standard error แล้วหา z = (pB - pA) / se) เก็บไว้ในตัวแปร z_stat
คำนวณ p-value สองด้าน (two-sided) ของ z_stat ด้านบน ด้วย scipy.stats.norm เก็บไว้ในตัวแปร p_value
🟡 ประยุกต์
ผลจาก e5 มีนัยสำคัญที่ α=0.05 หรือไม่ (p_value < 0.05) เก็บคำตอบ True/False ไว้ในตัวแปร is_significant
คำนวณ chi-square statistic จากตาราง contingency 2×2 ([[nA-bA, bA], [nB-bB, bB]]) ด้วย
scipy.stats.chi2_contingency เก็บไว้ในตัวแปร chi2_stat
คำนวณ sample ratio ของ variant A (nA / (nA + nB)) เพื่อเช็ค sample ratio mismatch (SRM)
เก็บไว้ในตัวแปร sample_ratio
🔴 ท้าทาย
ในบรรดา session ที่ไปถึงขั้น checkout แล้ว มีกี่เปอร์เซ็นต์ที่ไปถึง purchase ต่อ (checkout→purchase conversion) คำนวณแยกตาม variant เก็บไว้ในตัวแปร conv_A และ conv_B (ปัด 4 ตำแหน่ง)
ถ้าทีมหยุดทดลองตอน session สะสมถึง 40% ของทั้งหมด (n=6400 จาก 16000 เรียง session ตามเวลาที่เข้ามา จริงก่อน-หลัง) two-sided p-value ของ two-proportion z-test ณ จุดนั้นจะเป็นเท่าไร เก็บไว้ในตัวแปร p_at_40pct
จริงหรือเท็จ: การหยุดทดลองที่จุด 40% (จาก e10) จะทำให้สรุปผิดว่า "ไม่มีนัยสำคัญ" ทั้งที่ผลเต็มตัวอย่าง (p ≈ 0.000161) แสดงว่ามีผลจริง เก็บคำตอบ True/False ไว้ในตัวแปร would_miss_effect
Quiz
สรุป
- two-proportion z-test เทียบสัดส่วนสองกลุ่ม (เช่น purchase rate) ด้วย p_pool, standard error และ z-statistic
- chi-square test บนตาราง 2×2 คืออีกวิธีที่ทดสอบสมมติฐานเดียวกัน ควรให้ข้อสรุปสอดคล้องกับ z-test
- ก่อนเชื่อผล A/B test ต้องเช็ค sample ratio mismatch (SRM) ก่อนเสมอ ไม่ว่า p-value จะสวยแค่ไหน
- ดู funnel ทีละขั้นแทนที่จะดูแค่ metric สุดท้าย อาจเจอ insight ที่ metric รวมซ่อนไว้
- หยุดทดลองก่อนกำหนดเพราะตัวอย่างยังไม่พอ เสี่ยงพลาดผลจริงที่ยังไม่ทันแสดงตัว ควรกำหนดขนาดตัวอย่างล่วงหน้า
คำถามสัมภาษณ์
ก่อนเชื่อผล A/B test ควรเช็คอะไรก่อนดูที่ p-value
ควรเช็ค sample ratio mismatch (SRM) ก่อนเสมอ คือดูว่าสัดส่วนผู้เข้าทดลองสองกลุ่มใกล้เคียงกับที่ตั้งใจไหม (ปกติควรใกล้ 50/50 ถ้าออกแบบมาแบบนั้น) ถ้าสัดส่วนเบี้ยวไปมาก (เช่น 46/54) แปลว่าระบบสุ่มเข้ากลุ่มอาจมีบั๊ก ทำให้สองกลุ่มมีองค์ประกอบผู้ใช้ต่างกันไปตั้งแต่แรก ซึ่งจะทำให้ผลการทดสอบทั้งหมด (ไม่ว่า p-value จะสวย แค่ไหน) เชื่อถือไม่ได้ นอกจากนี้ควรดู funnel ทีละขั้นด้วย ไม่ใช่แค่ metric สุดท้าย เพื่อให้เข้าใจว่าความ ต่างเกิดขึ้นตรงไหนจริงๆ
อธิบาย sample ratio mismatch และทำไมมันอันตราย
SRM คือสถานการณ์ที่สัดส่วนผู้เข้าทดลองสองกลุ่มไม่ตรงกับที่ออกแบบไว้ เช่น ตั้งใจแบ่ง 50/50 แต่ข้อมูลจริง ออกมา 46/54 มันอันตรายเพราะมักเป็นสัญญาณว่ากระบวนการสุ่มเข้ากลุ่ม (randomization) มีบั๊ก เช่น cache, redirect, หรือ bot บาง segment ตกไปกลุ่มใดกลุ่มหนึ่งมากผิดปกติ — ถ้าเป็นแบบนั้น สองกลุ่มอาจมีองค์ประกอบ ผู้ใช้ต่างกันไปตั้งแต่แรก (ไม่ใช่สุ่มจริง) ทำให้ผลต่างที่วัดได้อาจไม่ได้มาจากตัวแปรที่ทดลองเลย แม้ p-value จะดูมีนัยสำคัญก็ตาม จึงต้องเช็คก่อนเชื่อผลทุกครั้ง
ทีมอยากหยุดทดลองเร็วเพราะเห็นตัวเลขดีขึ้นแล้ว ควรแนะนำยังไง
ควรแนะนำให้กำหนดขนาดตัวอย่าง (sample size) ที่ต้องการล่วงหน้าก่อนเริ่มทดลอง (power analysis) แล้วรอ จนเก็บข้อมูลครบตามแผน เพราะ p-value ที่เห็นระหว่างทางอาจยังไม่สะท้อนผลจริง (ตัวอย่างยังน้อยไป สถิติยัง ไม่นิ่ง) — ถ้าจำเป็นต้องดูผลระหว่างทางจริงๆ ควรใช้วิธี sequential testing ที่ออกแบบมาสำหรับกรณีนี้ โดยเฉพาะ แทนที่จะหยุดทันทีที่เห็นตัวเลขดูดีหรือ p < 0.05 ครั้งแรก ซึ่งเพิ่มความเสี่ยงทั้งพลาดผลจริง (ถ้าหยุดเร็วไป) และได้ผลบวกลวง (ถ้า peek บ่อยเกินไป)