Mert Özen Her Satırda Daha İleri
Proje Yönetimi Product Management'a Giriş — #08

Deneyle Karar Vermek: A/B Test, Feature Flag ve İstatistiksel Tuzaklar

Mert Özen 8 Kas 2026 13 dk 2 görüntülenme
Deneyle Karar Vermek: A/B Test, Feature Flag ve İstatistiksel Tuzaklar

Bir A/B testi, örneklemi deneyden önce hesaplanıp sonuna kadar beklendiğinde güvenilirdir. Bookstore'un kargo ücreti hipotezini Microsoft.FeatureManagement variant flag'iyle test ediyor; kol başına 3.954 sepet gerektiğini, SRM kontrolünü, 2,24 puanlık farkın güven aralığını ve her gün bakıp erken durmanın etkisiz A/A deneylerinde yanlış pozitif oranını %5'ten %32'ye çıkardığını C# çıktısıyla görüyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ve Microsoft.FeatureManagement 4.8.0 ile derlenip çalıştırılmıştır.

Önce Tahmin Et

Bookstore ekibi 02. yazıdaki hipotezi test etmeye başladı: ürün sayfasında kargo ücretini göstermek, sepete ekleyenlerin siparişi tamamlama oranını artırır mı? Ziyaretçilerin yarısı eski sayfayı, yarısı kargo ücretli sayfayı görüyor. Birinci haftanın sonunda dashboard şunu gösteriyor: kontrol grubunda sepete ekleyenlerin %33,0'ı sipariş vermiş, yeni sayfada %36,1'i; p-value 0,03.

Ürün sahibi heyecanlı: "İstatistiksel olarak anlamlı, deneyi durdurup herkese açalım." Bu karar doğru mu? Bir de şunu düşün: ekip bu dashboard'a deneyin ilk gününden beri her sabah bakıyordu. Bu, p-value'nun anlamını değiştirir mi?

Hipotezden Deneye

Bir A/B testi, yani kontrollü deney, bir hipotezi test etmenin en güçlü yoludur: kullanıcıları rastgele iki gruba ayırır, birine değişikliği gösterir, diğerine göstermezsin; aradaki farkın sebebi, rastgele atama sayesinde değişikliğin kendisidir. Ama deneyin işe yaraması, kodu yazmadan önce verilen birkaç karara bağlıdır.

Birinci karar primary metric: deneyin başarısını tek bir metric belirler, sonradan seçilmez. Bookstore için bu, 07. yazıdaki funnel'ın cart_item_added adımından order_placed adımına dönüşüm oranı; bugünkü değeri %33,3. İkinci karar guardrail'ler: ortalama sepet tutarı ve ürün sayfasının 95. percentile yanıt süresi kötüleşmemeli. Üçüncü karar randomizasyon birimi: oturum değil kullanıcı, çünkü aynı kişinin bir ziyarette kargo ücretini görüp ertesi gün görmemesi hem deneyimi hem ölçümü bozar. Dördüncü karar süre ve örneklem: kaç kullanıcıya, ne kadar süre ihtiyaç var? Bu sorunun cevabı deneyden önce hesaplanır.

Kohavi, Tang ve Xu'nun "Trustworthy Online Controlled Experiments" (2020) kitabı bu kararların her birini ayrıntılı tartışır; bu yazı bir giriş, o kitap bu konunun başvuru kaynağı.

Sample Size ve Minimum Detectable Effect

Bir deneyin kaç kullanıcıya ihtiyaç duyduğu üç şeye bağlıdır: baseline oran, yakalamak istediğin en küçük fark, yani MDE (Minimum Detectable Effect), ve kabul ettiğin hata oranları. Yaygın tercih alfa = 0,05 (gerçekte fark yokken "fark var" deme olasılığı) ve power = 0,80 (gerçekte MDE kadar fark varken onu yakalama olasılığı). Hipotezimiz "en az üç puan" dediği için MDE üç puan.

Formül, iki oranın farkı için standart yaklaşımdır: kol başına n = (z(1 − α/2) + z(1 − β))² × [p1(1 − p1) + p2(1 − p2)] / (p2 − p1)². Aşağıdaki kodda SampleSizePerArm bunu hesaplıyor: %33,3 baseline ve üç puanlık MDE için kol başına 3.954 sepet, toplam 7.908. 07. yazıdaki funnel'a göre Bookstore'da dört haftada 5.192 kişi sepete ekliyor, yani haftada yaklaşık 1.300. Deney en az altı hafta sürmeli.

Bu hesap, ekibin ilk hafta neden karar vermemesi gerektiğini de söylüyor: bir haftada yalnız 1.300 sepet var, gereken örneklemin altıda biri. MDE'yi yarıya, 1,5 puana indirmek isteseydin örneklem kabaca dört katına çıkardı; küçük etkileri yakalamak, trafiğin karesiyle pahalanır.

Kod: Variant Feature Flag ile Atama

Kullanıcıyı varyantlara atamak için Microsoft.FeatureManagement'ın variant feature flag'lerini kullanıyoruz. Flag mekaniği, IVariantFeatureManager'ın detayları ve ASP.NET Core'a entegrasyonu Deployment Stratejileri serisinin feature flag yazısında anlatıldı; burada yalnız deney tasarımı ve analiz için gereken kısmı kullanıyoruz. Configuration:

{
  "feature_management": {
    "feature_flags": [
      {
        "id": "ShippingCostOnProductPage",
        "enabled": true,
        "variants": [
          { "name": "Control" },
          { "name": "ShowShipping" }
        ],
        "allocation": {
          "percentile": [
            { "variant": "Control", "from": 0, "to": 50 },
            { "variant": "ShowShipping", "from": 50, "to": 100 }
          ],
          "seed": "shipping-cost-2026-11",
          "default_when_enabled": "Control"
        }
      }
    ]
  }
}

allocation.percentile, kullanıcıları 0–100 aralığına dağıtır ve 0–50'yi Control'e, 50–100'ü ShowShipping'e verir. Dağılım, kullanıcı kimliği ve seed değerinden hesaplanan deterministik bir hash'e dayanır; aynı kullanıcı her çağrıda aynı varyantı alır ve seed'i değiştirmek, bir sonraki deneyde kullanıcıları yeniden karıştırır. İstatistik yardımcıları:

namespace Bookstore.Experiments;

public sealed record ProportionTest(double Diff, double Low, double High, double P);

public static class Stats
{
    // Standart normal dağılımın CDF'i (Abramowitz-Stegun 7.1.26 erf yaklaşımı, hata < 1.5e-7)
    public static double NormalCdf(double z)
    {
        double x = Math.Abs(z) / Math.Sqrt(2);
        double t = 1 / (1 + 0.3275911 * x);
        double erf = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.Exp(-x * x);
        return z >= 0 ? 0.5 * (1 + erf) : 0.5 * (1 - erf);
    }

    // İki oranın farkı için z testi; p-value iki yönlü
    public static ProportionTest TwoProportions(int c1, int n1, int c2, int n2)
    {
        double p1 = (double)c1 / n1, p2 = (double)c2 / n2;
        double pooled = (double)(c1 + c2) / (n1 + n2);
        double z = (p2 - p1) / Math.Sqrt(pooled * (1 - pooled) * (1.0 / n1 + 1.0 / n2));
        double se = Math.Sqrt(p1 * (1 - p1) / n1 + p2 * (1 - p2) / n2);
        return new ProportionTest(p2 - p1, p2 - p1 - 1.96 * se, p2 - p1 + 1.96 * se, 2 * (1 - NormalCdf(Math.Abs(z))));
    }

    // Kol başına gereken örneklem: alfa 0.05 (iki yönlü), power 0.80
    public static int SampleSizePerArm(double baseline, double mde) =>
        (int)Math.Ceiling(Math.Pow(1.959964 + 0.841621, 2)
            * (baseline * (1 - baseline) + (baseline + mde) * (1 - baseline - mde)) / (mde * mde));

    // Sample ratio mismatch: beklenen 50/50 bölünmeye karşı ki-kare (1 serbestlik derecesi)
    public static double SrmPValue(int a, int b)
    {
        double expected = (a + b) / 2.0;
        double chi2 = Math.Pow(a - expected, 2) / expected + Math.Pow(b - expected, 2) / expected;
        return 2 * (1 - NormalCdf(Math.Sqrt(chi2)));
    }
}

Program dört adımı sırayla yapıyor: gereken örneklemi hesaplıyor, o kadar simüle edilmiş kullanıcıyı flag üzerinden varyantlara atıyor (gerçek etkiyi +3 puan olarak simüle ediyoruz), sonucu analiz ediyor ve son olarak peeking'in etkisini 2.000 A/A deneyiyle ölçüyor. Projenin .csproj'unda appsettings.json'ın output klasörüne kopyalanması gerekiyor (<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />); ilk çalıştırmada bunu unuttuk ve FileNotFoundException aldık.

using System.Globalization;
using Bookstore.Experiments;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.FeatureManagement;
using Microsoft.FeatureManagement.FeatureFilters;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build();
ServiceCollection services = new();
services.AddSingleton(config);
services.AddFeatureManagement();
await using ServiceProvider provider = services.BuildServiceProvider();
IVariantFeatureManager features = provider.GetRequiredService<IVariantFeatureManager>();

// 1) Deneyden önce: kaç kullanıcı gerekiyor?
double baseline = 0.333;   // 07'deki funnel: cart_item_added -> order_placed
double mde = 0.03;         // yakalamak istediğimiz en küçük fark: 3 puan
int perArm = Stats.SampleSizePerArm(baseline, mde);
Console.WriteLine($"Needed per arm: {perArm} carts (baseline {baseline * 100:F1}%, MDE {mde * 100:F0} points)");

// 2) Atama: aynı kullanıcı her seferinde aynı varyantı alır
Dictionary<string, int> assigned = new() { ["Control"] = 0, ["ShowShipping"] = 0 };
Dictionary<string, int> converted = new() { ["Control"] = 0, ["ShowShipping"] = 0 };
Random outcome = new(11);
for (int i = 1; i <= 2 * perArm; i++)
{
    TargetingContext user = new() { UserId = $"customer-{i:D6}" };
    Variant variant = await features.GetVariantAsync("ShippingCostOnProductPage", user, CancellationToken.None);
    assigned[variant.Name]++;

    // Simülasyon: gerçek etki +3 puan olsun
    double rate = variant.Name == "ShowShipping" ? baseline + 0.03 : baseline;
    if (outcome.NextDouble() < rate)
        converted[variant.Name]++;
}

Variant again = await features.GetVariantAsync("ShippingCostOnProductPage", new TargetingContext { UserId = "customer-000042" }, CancellationToken.None);
Console.WriteLine($"customer-000042 -> {again.Name} (stable on every call)");
Console.WriteLine($"Assigned: Control {assigned["Control"]}, ShowShipping {assigned["ShowShipping"]}, " +
    $"SRM p = {Stats.SrmPValue(assigned["Control"], assigned["ShowShipping"]):F3}");

// 3) Analiz: fark, %95 güven aralığı, p-value
ProportionTest r = Stats.TwoProportions(converted["Control"], assigned["Control"], converted["ShowShipping"], assigned["ShowShipping"]);
Console.WriteLine($"Control {100.0 * converted["Control"] / assigned["Control"]:F1}%, ShowShipping {100.0 * converted["ShowShipping"] / assigned["ShowShipping"]:F1}%");
Console.WriteLine($"Difference {r.Diff * 100:F2} points, 95% CI [{r.Low * 100:F2}, {r.High * 100:F2}], p = {r.P:F4}");

// 4) Peeking: etkisi OLMAYAN 2.000 A/A deneyi; her gün bakıp p < 0.05'te durursak?
Random aa = new(7);
int days = 42, perDay = (int)Math.Ceiling(2.0 * perArm / days);
int stoppedEarly = 0, significantAtEnd = 0;
for (int run = 0; run < 2000; run++)
{
    int cA = 0, nA = 0, cB = 0, nB = 0;
    bool stopped = false;
    for (int day = 1; day <= days; day++)
    {
        for (int k = 0; k < perDay; k++)
        {
            if (aa.Next(2) == 0) { nA++; if (aa.NextDouble() < baseline) cA++; }
            else { nB++; if (aa.NextDouble() < baseline) cB++; }
        }
        if (!stopped && Stats.TwoProportions(cA, nA, cB, nB).P < 0.05)
            stopped = true;
    }
    if (stopped) stoppedEarly++;
    if (Stats.TwoProportions(cA, nA, cB, nB).P < 0.05) significantAtEnd++;
}
Console.WriteLine($"A/A tests with no real effect: false positives looking once at the end = {significantAtEnd / 20.0:F1}%, " +
    $"peeking daily = {stoppedEarly / 20.0:F1}%");

Gerçek çıktı:

Needed per arm: 3954 carts (baseline 33.3%, MDE 3 points)
customer-000042 -> Control (stable on every call)
Assigned: Control 4008, ShowShipping 3900, SRM p = 0.225
Control 33.5%, ShowShipping 35.7%
Difference 2.24 points, 95% CI [0.14, 4.33], p = 0.0367
A/A tests with no real effect: false positives looking once at the end = 5.2%, peeking daily = 31.9%

Sample Ratio Mismatch

Analize geçmeden önce ilk kontrol, atamanın beklendiği gibi olup olmadığıdır. 50/50 bir bölünmede 7.908 kullanıcının 4.008'i Control'e, 3.900'ü ShowShipping'e düştü. Bu fark şans eseri mi? Ki-kare testi p = 0,225 diyor: bu büyüklükte bir fark rastgele atamada sık görülür, sorun yok.

SRM (Sample Ratio Mismatch), bu testin p-value'su çok küçük çıktığında, örneğin 0,001'in altında, alarm verir. SRM varsa deneyin sonucuna güvenilmez, çünkü gruplar artık rastgele değildir. Sebepleri çoğu zaman tekniktir: yeni varyant sayfası daha yavaş yüklendiği için bazı kullanıcıların event'i hiç gelmez, bot filtresi bir varyantı diğerinden çok etkiler, CDN (Content Delivery Network) cache'i bir varyantın sayfasını herkese gösterir ya da consent banner'ı bir varyantta farklı davranır. Microsoft'taki deney platformu ekibinden Fabijan ve arkadaşlarının KDD (Knowledge Discovery and Data Mining) 2019'da yayımladığı makale, SRM'in ne kadar yaygın olduğunu ve sebeplerinin bir taksonomisini verir. Kural basit: SRM varsa sonucu yorumlama, önce sebebini bul.

Analiz: Fark, Güven Aralığı, p-value

Control'de dönüşüm %33,5, ShowShipping'de %35,7: fark 2,24 puan. %95 güven aralığı [0,14; 4,33], p = 0,0367. Bunu şöyle okuruz: veri, kargo ücretini göstermenin dönüşümü artırdığını destekliyor; gerçek etkinin büyüklüğü ise 0,1 puan kadar küçük de, 4,3 puan kadar büyük de olabilir.

Bu sonucun iki ince noktası var. Birincisi, simülasyonda gerçek etkiyi +3 puan koyduk ama ölçülen fark 2,24 puan. Örneklem hatası tam olarak budur ve tek bir deneyin nokta tahmininin gerçek değerden uzak olabileceğini gösterir. İkincisi, güven aralığının alt sınırı sıfıra çok yakın. Ekip "üç puan artırır" hipotezini kanıtlamış değil; "bir artış var, büyüklüğü belirsiz" sonucuna ulaşmış durumda.

Karar yalnız p-value'ya bırakılmaz. Guardrail'lere bakılır: ortalama sepet tutarı düştü mü, ürün sayfası yavaşladı mı? Bu simülasyonda guardrail'leri modellemedik; gerçek bir deneyde onlar da aynı tabloda raporlanır ve biri belirgin şekilde kötüleşmişse, primary metric'teki kazanç tek başına yeterli değildir. Maliyet de hesaba girer: ürün sayfasında kargo ücretini hesaplamanın bir bakım maliyeti var. Bookstore ekibi bu sonuçla değişikliği herkese açtı ve önümüzdeki dört hafta boyunca funnel'ı izlemeye karar verdi.

Peeking Problemi

Baştaki hikâyeye dönelim: ekip deneyin ilk gününden beri her sabah dashboard'a bakıyordu ve p < 0,05 gördüğü ilk anda durmak istedi. Programın son satırı bunun neden tehlikeli olduğunu gösteriyor. Gerçekte hiçbir fark olmayan 2.000 A/A deneyi simüle ettik, yani iki grup da aynı %33,3 oranında dönüşüyor. Yalnız deneyin sonunda bir kez bakıldığında deneylerin %5,2'si yanlışlıkla "anlamlı" çıktı; bu, alfa = 0,05'in tam olarak vaat ettiği şey. Her gün bakıp p < 0,05 görülen ilk günde durulduğunda ise deneylerin %31,9'u yanlışlıkla "kazanan" ilan edildi.

Sebep şu: p-value, deney önceden belirlenmiş bir örneklemde bir kez bakılacakmış gibi hesaplanır. Her bakış yeni bir şanstır ve rastgele dalgalanma deney boyunca bir noktada eşiğin altına inmeye yatkındır. Kırk iki bakışta en az bir kez inme olasılığı, yüzde beşten çok daha yüksektir.

Çözüm ya örneklemi önceden belirleyip ona kadar karar vermemektir ya da sürekli bakmaya uygun tasarlanmış sequential test yöntemlerini kullanmaktır. Dashboard'a bakmak yasak değil; guardrail'lerde bir felaket var mı diye bakmak gerekir. Yasak olan, primary metric'e bakıp erken zafer ilan etmektir.

Ne Zaman A/B Test Yapma

A/B test güçlüdür ama her soru için doğru araç değildir. Düşük trafikte deney aylar sürer: Bookstore'da yalnız misafir kullanıcıları hedefleyen bir deney, aynı MDE için yaklaşık 1,4 katı süre, sekiz dokuz hafta ister, çünkü sepete ekleyenlerin yalnız üçte ikisi misafir ve misafirlerin baseline oranı (%27,9) farklı. Daha küçük bir etkiyi yakalamak istersen süre yıllara uzar. Bu durumda deney yerine 03. yazıdaki kalitatif yöntemler, yani görüşmeler ve prototip testleri, daha hızlı ve daha çok bilgi verir.

Geri alınamaz değişiklikler deneye uygun değildir. Fiyat politikası, marka değişikliği ya da müşteriye verilmiş bir söz, iki gruba ayrılıp sonra geri alınamaz; deney bittiğinde kontrol grubuna "aslında bu sizin için değildi" diyemezsin.

Etik ve regülasyon sınırları vardır. Aynı ürüne farklı kullanıcılara farklı fiyat göstermek, müşteri güveni ve tüketici mevzuatı açısından risklidir. Kişisel veriyle yapılan deneylerde 07. yazıdaki KVKK (Kişisel Verilerin Korunması Kanunu) ve GDPR (General Data Protection Regulation) kontrol listesi aynen geçerlidir. Şüphedeysen hukuk ekibine sor.

Üretimde Dikkat

Deney planını deneyden önce yaz: hipotez, primary metric, guardrail'ler, MDE, örneklem, süre ve karar kuralı. Bu tek sayfa, sonuç geldiğinde "aslında şu metric'e bakmamız lazımdı" tartışmasını engeller. 09. yazıdaki one-pager'ın success metric bölümü tam olarak bunu taşır.

Atamayı event'e yaz. Kullanıcının hangi varyanta düştüğü, 07. yazıdaki event tablosunda bir property olarak (ör. experiment ve variant) saklanmazsa, analizde funnel'ı varyanta göre bölemezsin. Taxonomy'ye bu property'leri eklemek deneyin ön koşuludur.

Deney bitince flag'i kaldır. Kazanan varyant herkese açıldıktan sonra flag ve kontrol kolunun kodu backlog'a bir temizlik item'ı olarak girmeli; yıllarca kodda kalan deney flag'leri, Deployment Stratejileri serisinde anlatılan flag borcunun en yaygın kaynağıdır.

Sık Düşülen Tuzaklar

Novelty effect: kullanıcılar yeni bir şeye ilk günlerde sırf yeni olduğu için daha çok tepki verir. Bir değişikliğin etkisi ilk hafta büyük görünüp sonra sönebilir. Tersi de olur: alışkanlıklarından vazgeçmek istemeyen kullanıcılar yeni tasarıma ilk günlerde direnç gösterir. Deneyi en az bir iki tam hafta çalıştırmak ve etkiyi zamana göre çizmek bu iki etkiyi görmeyi sağlar.

Sonradan segment avı: sonuç anlamsız çıktığında "peki yalnız mobil kullanıcılarda? yalnız İstanbul'da? yalnız salı günleri?" diye segment segment bakmak, yeterince segmente bakınca bir yerde mutlaka "anlamlı" bir sonuç bulur. Segmentler deneyden önce plana yazılmadıysa, bulunan sonuç yeni bir deneyin hipotezidir, kanıt değil.

Çok sayıda metric'e bakıp birini seçmek de aynı hatanın başka bir biçimi. Yirmi metric'e bakarsan, gerçekte hiçbir etki yokken bile bir tanesinin %5 eşiğini geçmesi beklenir. Primary metric'i önceden seç.

Son olarak, anlamlı olmayan sonucu "etki yok" diye okumak. Power %80 olan bir deney, gerçek etkiyi beş seferden birinde kaçırır. Bizim ilk denememizde, kargo ücreti yerine tek adımlı ödemeyi test ederken, gerçek +3 puanlık etki p = 0,055 ile eşiğin hemen üstünde kalmıştı. "Kanıt bulamadık" ile "etki yok" aynı cümle değildir.

Küçük Bir Deneme

★☆☆ Programda MDE'yi 0,03'ten 0,015'e indir ve gereken örneklemi bul. Bookstore'un haftalık 1.300 sepetiyle bu deney kaç hafta sürerdi?

★★☆ appsettings.json'daki allocation'ı bilerek 0–55 ve 55–100 yap ama analizi 50/50 bekleyerek çalıştır. SRM testi bunu yakalıyor mu? Kaç kullanıcıda yakalamaya başlıyor?

★★★ Peeking simülasyonuna basit bir düzeltme ekle: günlük bakışlarda p < 0,05 yerine çok daha katı bir eşik (ör. 0,005) kullan, son günde yine 0,05. Yanlış pozitif oranı ne oluyor? Bu, sequential test yöntemlerinin arkasındaki fikrin kaba bir versiyonu.

Deneyle karar vermeyi gördük. Serinin son yazısında discovery'den çıkan kararın backlog'a nasıl dönüştüğünü konuşacağız: one-pager ve PRD (Product Requirements Document) iskeletini, success metric ve non-goal bölümlerini ve bir geliştiricinin PRD review'da sorması gereken soruları Bookstore'un sepet terk çalışması üzerinden göreceğiz.