Veriden karara: neden istatistik?
Anadolu Market yeni bir kampanya sayfası denedi: ziyaretçilerin yarısı eski sayfayı (A), yarısı yeni sayfayı (B) gördü. A'da dönüşüm %12, B'de %15. Yönetim soruyor: "B'yi herkese açalım mı?" Üç puanlık fark gerçek bir iyileşme mi, yoksa şans eseri mi? Bin ziyaretçiyle mi ölçtük, yüz ziyaretçiyle mi? Bu sorunun cevabı tahmin değil, istatistiksel çıkarımdır (statistical inference).
Bu derste betimsel istatistikten başlayıp dağılımlara, örneklemeye, güven aralığına, hipotez testlerine (t, ki-kare, korelasyon), A/B testine, etki büyüklüğüne ve doğrusal regresyona ilerleyeceğiz. Araçlar: numpy (bunu numpy: sayısal diziler ve vektörleştirme dersinde gördün), pandas, scipy.stats ve statsmodels.
Betimsel istatistik: veriyi özetlemek
Her analiz veriye bakarak başlar. Merkez (ortalama, medyan), yayılım (standart sapma, çeyrekler arası açıklık) ve şekil (çarpıklık) üç temel sorudur.
import numpy as np
import pandas as pd
from scipy import stats
sepet = pd.Series([120, 95, 410, 260, 180, 80, 150, 210, 175, 140, 190, 2400])
print("ortalama:", round(sepet.mean(), 1), "| medyan:", sepet.median())
print("std (örneklem, ddof=1):", round(sepet.std(), 1), "| numpy varsayılanı ddof=0:", round(np.std(sepet), 1))
q1, q3 = sepet.quantile([0.25, 0.75])
print("Q1:", q1, "Q3:", q3, "IQR:", q3 - q1)
print("çarpıklık:", round(stats.skew(sepet), 2))
print(sepet.describe().round(1))
Tek bir 2400 TL'lik sipariş ortalamayı 367 TL'ye çekti, medyan 177,5 TL'de kaldı. Gelir, sepet tutarı, bekleme süresi gibi sağa çarpık verilerde "tipik değer" için medyan, yayılım için IQR daha dürüsttür.
| Ölçü | pandas / numpy | Not |
|---|---|---|
| Ortalama, medyan | mean(), median() | Aykırı değer ortalamayı çeker |
| Standart sapma | s.std() (ddof=1), np.std(x) (ddof=0) | Örneklemde ddof=1 kullan |
| Çeyrekler, IQR | quantile([.25, .75]) | Aykırı değere dayanıklı |
| Çarpıklık | stats.skew | > 0: sağ kuyruk uzun |
| Mod | s.mode() | Kategorik veride anlamlı |
np.std varsayılan olarak nüfus (population) formülünü (n'e böler), pandas.Series.std örneklem formülünü (n-1'e böler) kullanır. Küçük veride fark büyüktür. Örneklemden çıkarım yapıyorsan ddof=1.Olasılık dağılımları: scipy.stats
Gerçek süreçler bilinen dağılımlarla iyi modellenir. Normal dağılım ölçüm hatası ve ortalamalar için, binom "n denemede kaç başarı" (100 siparişten kaçı iade?), Poisson "birim zamanda kaç olay" (çağrı merkezine saatte kaç arama?) için kullanılır. scipy'de her dağılımın aynı yöntemleri vardır.
| Yöntem | Soru | Örnek |
|---|---|---|
pmf(k) / pdf(x) | Tam olarak k olasılığı (yoğunluk) | binom.pmf(2, 10, 0.1) |
cdf(x) | x veya daha az | norm.cdf(1.96) ≈ 0.975 |
sf(x) | x'ten büyük (1 - cdf) | poisson.sf(4, 2.5): 4'ten fazla |
ppf(q) | q yüzdelik dilimin değeri | norm.ppf(0.975) ≈ 1.96 |
rvs(size, random_state) | Rastgele örnek üret | Simülasyon |
from scipy import stats
# Teslimat süresi: ortalama 48 saat, std 8 saat (normal)
teslim = stats.norm(loc=48, scale=8)
print("72 saati aşma olasılığı:", round(teslim.sf(72), 4))
print("siparişlerin %95'i kaç saatte teslim:", round(teslim.ppf(0.95), 1))
# İade oranı %10, günde 10 sipariş (binom): tam 2 iade, en az 1 iade
print("tam 2 iade:", round(stats.binom.pmf(2, 10, 0.10), 4))
print("en az 1 iade:", round(1 - stats.binom.pmf(0, 10, 0.10), 4))
# Ilgaz Bank çağrı merkezi: saatte ortalama 2.5 şikâyet (Poisson)
print("bir saatte 4'ten fazla şikâyet:", round(stats.poisson.sf(4, 2.5), 4))
sf(k - 1), "k'dan fazla" için sf(k) yaz. Bir eksik/fazla yazılan k, en sık yapılan olasılık hatasıdır.Örnekleme ve merkezi limit teoremi
Tüm müşterileri ölçemezsin; bir örneklem (sample) alırsın. Merkezi limit teoremi (central limit theorem) der ki: popülasyon dağılımı ne olursa olsun (çarpık bile), yeterince büyük örneklemlerin ortalamaları yaklaşık normal dağılır ve bu ortalamaların standart sapması (standart hata) σ / √n olur. Güven aralıklarının ve t-testlerinin çalışmasının sebebi budur.
import numpy as np
rng = np.random.default_rng(42)
nufus = rng.exponential(scale=200, size=100_000) # çok çarpık: sepet tutarı
print("nüfus ortalaması:", round(nufus.mean(), 1), "| std:", round(nufus.std(), 1))
for n in (5, 30, 200):
ortalamalar = rng.choice(nufus, size=(2000, n)).mean(axis=1)
print(f"n={n:>3}: ortalamaların ortalaması={ortalamalar.mean():6.1f} "
f"std hata={ortalamalar.std():5.1f} teorik={nufus.std() / np.sqrt(n):5.1f}")
Örneklem büyüdükçe ortalamalar gerçek değerin etrafında daralır; 4 kat büyük örneklem hatayı yarıya indirir. "Neden 30 değil 3000 kişiyle test ettik?" sorusunun cevabı bu √n'dir.
Güven aralığı
Örneklem ortalaması tek bir sayıdır; güven aralığı (confidence interval) bu tahminin ne kadar kesin olduğunu söyler. Küçük örneklemde ve σ bilinmediğinde t dağılımı kullanılır: ortalama ± t* · s / √n.
import numpy as np
from scipy import stats
bekleme = np.array([4.2, 6.1, 3.8, 7.5, 5.0, 4.9, 6.3, 5.5, 4.1, 5.8]) # dakika, şube kuyruğu
n = len(bekleme)
ort = bekleme.mean()
se = bekleme.std(ddof=1) / np.sqrt(n)
t_kritik = stats.t.ppf(0.975, df=n - 1)
print(f"elle: {ort - t_kritik * se:.2f} – {ort + t_kritik * se:.2f}")
alt, ust = stats.t.interval(0.95, df=n - 1, loc=ort, scale=se)
print(f"scipy: {alt:.2f} – {ust:.2f} (t*={t_kritik:.3f}, z ile 1.96 olurdu)")
Hipotez testi çerçevesi
Her test aynı iskeleti izler: (1) sıfır hipotezi H0 "fark yok", alternatif H1 "fark var"; (2) anlamlılık düzeyi α'yı önceden seç (genellikle 0.05); (3) test istatistiğini ve p-değerini hesapla; (4) p < α ise H0'ı reddet. p-değeri: H0 doğru olsaydı, gözlediğin kadar ya da daha uç bir sonuç görme olasılığı.
| Soru | Veri | Test |
|---|---|---|
| İki bağımsız grubun ortalaması farklı mı? | Sayısal, iki grup | ttest_ind(a, b, equal_var=False) (Welch) |
| Aynı birimlerde önce/sonra farkı var mı? | Sayısal, eşli | ttest_rel(once, sonra) |
| İki kategorik değişken ilişkili mi? | Sayım tablosu | chi2_contingency(tablo) |
| İki oran farklı mı? (A/B) | Başarı / deneme | proportions_ztest ya da ki-kare |
| İki sayısal değişken birlikte mi hareket ediyor? | Sayısal çiftler | pearsonr (doğrusal), spearmanr (sıralı) |
import numpy as np
from scipy import stats
rng = np.random.default_rng(7)
istanbul = rng.normal(310, 60, 40) # sepet tutarı
ankara = rng.normal(285, 75, 35)
t, p = stats.ttest_ind(istanbul, ankara, equal_var=False)
print(f"Welch t={t:.2f}, p={p:.4f}")
# Eşli: aynı 8 kasiyerin eğitim öncesi/sonrası işlem süresi (sn)
once = np.array([62, 58, 71, 66, 59, 73, 68, 64])
sonra = np.array([57, 55, 66, 63, 58, 67, 62, 61])
print("eşli t-testi p =", round(stats.ttest_rel(once, sonra).pvalue, 5))
print("yanlış (bağımsız) t-testi p =", round(stats.ttest_ind(once, sonra).pvalue, 4))
Eşli veride bağımsız test kullanmak, kasiyerler arasındaki doğal farkı gürültü olarak sayar ve gerçek etkiyi gizler: aynı veriyle p birkaç binde birden 0,05'in üstüne çıkar.
import pandas as pd
from scipy import stats
# Şehir ile ödeme yöntemi ilişkili mi? (sipariş sayıları)
tablo = pd.DataFrame({"Kart": [320, 210, 150], "Kapıda": [80, 90, 110]},
index=["İstanbul", "Ankara", "İzmir"])
chi2, p, dof, beklenen = stats.chi2_contingency(tablo)
print(f"ki-kare={chi2:.1f}, sd={dof}, p={p:.6f}")
print(pd.DataFrame(beklenen, index=tablo.index, columns=tablo.columns).round(1))
x = [1, 2, 3, 4, 5, 6]
y = [2, 4, 9, 15, 30, 70] # artan ama doğrusal değil
print("pearson:", round(stats.pearsonr(x, y)[0], 3), "| spearman:", round(stats.spearmanr(x, y)[0], 3))
Ki-kare testi gözlenen sayımları "ilişki olmasaydı" beklenecek sayımlarla kıyaslar. Spearman sıralar üzerinden çalıştığı için tekdüze (monoton) ama doğrusal olmayan ilişkide 1 verir; Pearson yalnızca doğrusallığı ölçer. Korelasyon nedensellik değildir: dondurma satışı ile klima satışı birlikte artar, ikisinin de sebebi sıcaklıktır.
A/B testi: Anadolu Market kampanyası
import numpy as np
from statsmodels.stats.proportion import proportions_ztest, confint_proportions_2indep
donusum = np.array([120, 150]) # A, B
ziyaret = np.array([1000, 1000])
oran = donusum / ziyaret
z, p = proportions_ztest(donusum, ziyaret)
lift = (oran[1] / oran[0] - 1) * 100
alt, ust = confint_proportions_2indep(donusum[1], ziyaret[1], donusum[0], ziyaret[0])
print(f"A={oran[0]:.1%} B={oran[1]:.1%} göreli artış={lift:.1f}%")
print(f"z={z:.3f} p={p:.4f}")
print(f"B - A farkı için %95 GA: {alt:.3%} … {ust:.3%}")
p ≈ 0.0496: sınırda. Güven aralığı farkın neredeyse 0'dan %6'ya kadar olabileceğini söylüyor. Doğru karar cümlesi: "B muhtemelen daha iyi, ama etkinin büyüklüğü belirsiz; daha büyük örneklemle doğrula." Testi her gün kontrol edip p 0.05'in altına ilk düştüğünde durdurmak (peeking) yanlış pozitif oranını katlar: örneklem büyüklüğünü önceden hesapla ve sonuna kadar bekle.
p-değeri hakkında sık yanlışlar ve etki büyüklüğü
| Yanlış | Doğrusu |
|---|---|
| "p = 0.03: H0'ın doğru olma olasılığı %3" | p, H0 doğruysa bu kadar uç veri görme olasılığıdır; H0'ın olasılığı değildir |
| "p > 0.05: fark yok, kanıtlandı" | Kanıt yetersiz demektir; örneklem küçükse gerçek fark kaçabilir (II. tip hata) |
| "p çok küçük: etki çok büyük" | Milyon satırda önemsiz farklar bile anlamlı çıkar; etki büyüklüğüne bak |
| "20 metrik test ettim, biri anlamlı" | α=0.05 ile 20 testte 1 yanlış pozitif beklenir; çoklu karşılaştırma düzeltmesi yap |
Etki büyüklüğü (effect size) farkın pratikte ne kadar önemli olduğunu ölçer. Oranlarda göreli artış (lift), ortalamalarda Cohen's d: iki ortalama farkının ortak standart sapmaya bölümü. Kabaca 0.2 küçük, 0.5 orta, 0.8 büyük sayılır. İstatistiksel anlamlılık "fark gerçek mi?", etki büyüklüğü "fark önemli mi?" sorusunu cevaplar; karar için ikisi birlikte gerekir.
statsmodels ile doğrusal regresyon
Regresyon, bir sonucu (ciro) birden çok açıklayıcı değişkenle (reklam bütçesi, indirim oranı) ilişkilendirir ve her birinin etkisini diğerleri sabitken tahmin eder. statsmodels'in formül arayüzü R'a benzer: "ciro ~ reklam + indirim".
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
rng = np.random.default_rng(3)
n = 60
df = pd.DataFrame({"reklam": rng.uniform(5, 50, n), "indirim": rng.uniform(0, 20, n)})
df["ciro"] = 120 + 2.5 * df["reklam"] + 1.2 * df["indirim"] + rng.normal(0, 15, n)
model = smf.ols("ciro ~ reklam + indirim", data=df).fit()
print(model.summary().tables[1])
print("R²:", round(model.rsquared, 3), "| düzeltilmiş R²:", round(model.rsquared_adj, 3))
print("reklam katsayısı GA:", model.conf_int().loc["reklam"].round(2).tolist())
print("tahmin:", model.predict(pd.DataFrame({"reklam": [30], "indirim": [10]})).round(1).tolist())
| Özet tablosu sütunu | Anlamı |
|---|---|
coef | Diğerleri sabitken değişkendeki 1 birim artışın sonuca etkisi (reklama +1 bin TL → ciro +2.5 bin TL) |
std err | Katsayı tahmininin belirsizliği |
t, P>|t| | "Katsayı 0" hipotezinin testi; küçük p, etkinin sıfırdan farklı olduğunu gösterir |
[0.025 0.975] | Katsayının %95 güven aralığı |
R-squared | Sonuçtaki değişkenliğin modelce açıklanan oranı; değişken eklendikçe hep artar, bu yüzden düzeltilmiş R²'ye de bak |
Derinleş: gerçek bir A/B testini uçtan uca okumak
Derste A/B testinin temel mantığını gördün. Gerçek bir deneyi raporlamadan önce kıdemli analistlerin sırayla sorduğu dört soru vardır: (1) Gruplar doğru bölündü mü? (SRM), (2) Dönüşüm farkı anlamlı mı ve ne kadar büyük? (oranlar için z-testi ve güven aralığı), (3) Sonuca erken mi baktık? (peeking), (4) Daha az kullanıcıyla aynı kesinliğe ulaşabilir miydik? (CUPED). Hepsini Anadolu Market'in gerçek deney tablosuyla yapacağız: anadolu_market/ab_test, 2.000 kullanıcı; variant A (eski tasarım) ya da B (yeni tasarım), converted 0/1, revenue kullanıcının getirdiği gelir.
import veri
ab = veri.oku("anadolu_market", "ab_test")
ozet = ab.groupby("variant").agg(kullanici=("user_id", "size"), donusum=("converted", "mean"),
gelir_kisi=("revenue", "mean"))
print(ozet.round(4))
1. Önce SRM: örneklem oranı uyumsuzluğu
Deney %50/%50 tasarlandıysa gruplar yaklaşık eşit olmalı. A'da 975, B'de 1.025 kullanıcı var. Bu fark şans eseri mi? SRM (sample ratio mismatch) kontrolü, beklenen oranla gözlenen sayıları ki-kare uygunluk testiyle karşılaştırır. SRM varsa (ör. B sayfası bazı tarayıcılarda çöküyor ve o kullanıcılar kaydedilmiyorsa) gruplar artık karşılaştırılabilir değildir ve hiçbir sonuç yorumlanmaz. Bu kontrol için çok küçük bir eşik (ör. p < 0.001) kullanılır; amaç ciddi kırılmaları yakalamaktır.
import veri
from scipy import stats
ab = veri.oku("anadolu_market", "ab_test")
n = ab["variant"].value_counts().reindex(["A", "B"])
beklenen = [n.sum() * 0.5, n.sum() * 0.5]
sonuc = stats.chisquare(n.values, f_exp=beklenen)
print(n.to_dict(), "p =", round(float(sonuc.pvalue), 4))
print("SRM var" if sonuc.pvalue < 0.001 else "SRM yok: analize devam")
2. Oranlar için z-testi ve fark için güven aralığı
İki dönüşüm oranını karşılaştırmanın standart yolu iki oran z-testidir. Test istatistiği birleşik (pooled) oranla hesaplanır: z = (p_B − p_A) / √(p(1−p)(1/n_A + 1/n_B)). Fark için güven aralığında ise her grubun kendi oranı kullanılır (birleştirilmemiş standart hata). Raporda p-değeri tek başına değil, farkın büyüklüğü ve aralığıyla birlikte verilir.
import veri
import numpy as np
from scipy import stats
from statsmodels.stats.proportion import proportions_ztest
ab = veri.oku("anadolu_market", "ab_test")
g = ab.groupby("variant")["converted"].agg(["sum", "count"])
xa, na = g.loc["A"]; xb, nb = g.loc["B"]
pa, pb = xa / na, xb / nb
p = (xa + xb) / (na + nb)
z = (pb - pa) / np.sqrt(p * (1 - p) * (1 / na + 1 / nb))
pdeg = 2 * stats.norm.sf(abs(z))
se = np.sqrt(pa * (1 - pa) / na + pb * (1 - pb) / nb)
print(f"A %{pa*100:.2f} B %{pb*100:.2f} fark {100*(pb-pa):.2f} puan")
print(f"z = {z:.3f}, p = {pdeg:.4f}, %95 GA: [{100*(pb-pa-1.96*se):.2f}, {100*(pb-pa+1.96*se):.2f}] puan")
print("statsmodels:", [round(float(v), 4) for v in proportions_ztest([xb, xa], [nb, na])])
Gelir gibi çarpık metriklerde (kullanıcıların %87'si 0 gelir getiriyor) ortalama farkı için Welch t-testi ya da daha güvenli olarak bootstrap güven aralığı kullanılır; derste gördüğün yöntem burada da geçerlidir.
3. Peeking: sonuca her gün bakmak
Deneyi her gün açıp "p < 0.05 oldu mu?" diye bakar ve olduğu gün durdurursan, yanlış pozitif oranın %5 değil çok daha yüksek olur. Aşağıda kullanıcılar geliş sırasına (user_id) göre birikirken p-değerinin nasıl dalgalandığını görüyorsun: 200 kullanıcıda p = 0.014 ile "anlamlı" görünüyor, sonra yüzlerce kullanıcı boyunca 0.05'in üstüne çıkıyor.
import veri
import numpy as np
from scipy import stats
ab = veri.oku("anadolu_market", "ab_test").sort_values("user_id")
def p_deger(s):
g = s.groupby("variant")["converted"].agg(["sum", "count"])
xa, na = g.loc["A"]; xb, nb = g.loc["B"]
p = (xa + xb) / (na + nb)
z = (xb / nb - xa / na) / np.sqrt(p * (1 - p) * (1 / na + 1 / nb))
return 2 * stats.norm.sf(abs(z))
yol = [round(float(p_deger(ab.iloc[:k])), 3) for k in range(200, 2001, 200)]
print(yol)
Gerçekte fark olmayan (A/A) binlerce deney simüle edersen, 10 kez bakıp ilk "anlamlı" sonuçta durmak yanlış pozitif oranını yaklaşık %5'ten %15–20'ye çıkarır. Çözümler: örneklem büyüklüğünü güç analiziyle önceden belirleyip yalnızca sonda bakmak; ara bakış gerekiyorsa sıralı test (sequential testing) kullanmak. Sıralı testlerde her ara bakışa α'nın bir kısmı harcanır (alpha spending; O'Brien-Fleming sınırları ilk bakışlarda çok katı, sonda neredeyse 0.05'tir) ya da her an geçerli (always-valid) p-değerleri üreten yöntemler (mSPRT) kullanılır. Ticari deney platformlarının "erken durdurma" düğmeleri bu yöntemlere dayanır.
4. CUPED: deney öncesi veriyle varyans azaltma
Kullanıcıların harcaması deneyden önce de çok farklıdır; bu doğal farklılık gürültü olarak test sonucuna girer. CUPED (Controlled-experiment Using Pre-Experiment Data), deney öncesi metrik X ile sonuç Y arasındaki ilişkiyi kullanarak gürültüyü çıkarır: θ = cov(Y, X) / var(X), Y_cuped = Y − θ (X − ort(X)). X deneyden önce ölçüldüğü için gruplardan bağımsızdır; bu yüzden ortalama fark değişmez (yansızdır), yalnızca varyans yaklaşık ρ² oranında düşer. Aynı kesinlik için daha az kullanıcı, yani daha kısa deney demektir.
ab_test tablosunda deney öncesi metrik yok. CUPED'i göstermek için aşağıda tohumlu (seed) bir simülasyon kullanıyoruz. Kendi şirketinde X genellikle deneyden önceki 2–4 haftanın aynı metriğidir (harcama, oturum sayısı).import numpy as np
def deney(seed=21, n=2000, etki=3.0):
rng = np.random.default_rng(seed)
onceki = rng.gamma(2.0, 30.0, n) # deney öncesi 30 günlük harcama
grup = rng.integers(0, 2, n) # 0 = A, 1 = B
sonraki = 0.8 * onceki + rng.normal(0, 20, n) + etki * grup
return onceki, grup, sonraki
from scipy import stats
x, g, y = deney()
theta = np.cov(y, x)[0, 1] / np.var(x, ddof=1)
y_c = y - theta * (x - x.mean())
for ad, m in [("ham", y), ("CUPED", y_c)]:
fark = m[g == 1].mean() - m[g == 0].mean()
p = stats.ttest_ind(m[g == 1], m[g == 0], equal_var=False).pvalue
print(f"{ad:6} fark={fark:.2f} varyans={m.var(ddof=1):.0f} p={p:.4f}")
Güç analizi: kaç kişi lazım?
Testin gücü (power), gerçek bir etki varken onu yakalama olasılığıdır (genellikle 0.8 hedeflenir). statsmodels.stats.power.NormalIndPower().solve_power(effect_size=..., alpha=0.05, power=0.8) grup başına gereken örneklemi verir; oranlar için etki büyüklüğü proportion_effectsize(0.15, 0.12) ile hesaplanır. %12'den %15'e çıkışı güvenle yakalamak için grup başına yaklaşık 2000 ziyaretçi gerekir; bin kişilik testin sınırda kalması bu yüzdendir.
Çoklu karşılaştırma ve Simpson paradoksu
Birçok segmentte ya da metrikte test yapıyorsan Bonferroni (α / test sayısı) ya da Benjamini-Hochberg (statsmodels.stats.multitest.multipletests) düzeltmesi uygula. Toplamda B kazanırken her şehirde A'nın kazanması mümkündür (Simpson paradoksu): grupların karışımı farklıysa kırılımlara mutlaka bak.
Varsayım yoksa: bootstrap ve permütasyon
Medyan gibi formülü zor istatistikler için bootstrap: veriden yerine koyarak binlerce örneklem çek, istatistiği her birinde hesapla, yüzdelik dilimlerden güven aralığı al (scipy.stats.bootstrap). Dağılım varsayımı olmayan iki grup karşılaştırması için permütasyon testi (scipy.stats.permutation_test) ya da Mann-Whitney U (mannwhitneyu) kullan.