Perhitungan kanibalisasi didasarkan pada uji A / B klasik dan metode bootstrap

Artikel ini membahas metode untuk menghitung kanibalisasi untuk aplikasi seluler berdasarkan uji A / B klasik. Dalam hal ini, tindakan target dipertimbangkan dan dievaluasi sebagai bagian dari proses re-atribusi dari sumber periklanan (Langsung, Criteo, AdWords UAC dan lainnya) dibandingkan dengan tindakan target dalam grup yang menonaktifkan iklan.

Artikel ini memberikan ikhtisar metode klasik untuk membandingkan sampel independen dengan dasar teori singkat dan deskripsi perpustakaan yang digunakan, termasuk menjelaskan secara singkat esensi dari metode bootstrap dan implementasinya di perpustakaan FaceBook Bootstrap, serta masalah yang muncul dalam praktik saat menerapkan teknik ini, dan bagaimana menyelesaikannya.

Bukti dikaburkan atau tidak disediakan untuk mempertahankan perjanjian tidak ada pengungkapan.

Di masa depan, saya berencana untuk menambah dan sedikit memodifikasi artikel ini saat fakta baru muncul, sehingga versi ini dapat dianggap sebagai rilis pertama. Saya akan berterima kasih atas komentar dan ulasannya.

Pendahuluan


Kanibalisasi adalah proses arus lalu lintas, lengkap dan tertarget, dari satu saluran ke saluran lainnya.

Pemasar biasanya menggunakan indikator ini sebagai koefisien K tambahan dalam menghitung BPA: BPA yang dihitung dikalikan dengan 1 + K. Dalam hal ini, BPA berarti total biaya untuk menarik lalu lintas / jumlah tindakan bertarget yang dimonetisasi secara langsung, yaitu, yang menghasilkan laba aktual - misalnya, panggilan bertarget, dan / atau dimonetisasi secara tidak langsung - misalnya, meningkatkan volume basis data iklan, meningkatkan pemirsa, dan sebagainya.

Ketika saluran gratis (misalnya, kunjungan dari SERP organik, klik pada tautan di situs yang bebas untuk kami gunakan) dikanibal untuk dibayar (Langsung, Adwords alih-alih organik, beriklan di umpan jejaring sosial alih-alih mengklik iklan, gratis ditempatkan dalam kelompok, dan sebagainya), ini disertai dengan risiko kerugian finansial, sehingga penting untuk mengetahui tingkat kanibalisasi.

Dalam kasus kami, tugasnya adalah menghitung kanibalisasi transisi "organik" ke aplikasi dengan transisi dari jaringan periklanan Criteo. Surveillance adalah perangkat atau cairan pengguna (GAID / ADVID dan IDFA).

Persiapan percobaan


Anda dapat menyiapkan audiens untuk eksperimen dengan membagi pengguna di antarmuka sistem analitik AdJust menjadi grup untuk mengisolasi mereka yang akan melihat iklan dari jaringan iklan tertentu (sampel kontrol) dan mereka yang tidak akan ditampilkan masing-masing menggunakan iklan menggunakan GAID atau ADVID dan IDFA. (AdJust menyediakan API Pembuat Pemirsa). Kemudian, dalam sampel kontrol, Anda dapat menyertakan kampanye iklan dalam jaringan iklan yang dipelajari dalam percobaan.

Saya perhatikan dari diri saya sendiri bahwa, karena tampaknya secara intuitif, percobaan berikut akan lebih kompeten dalam kasus ini: untuk memilih empat kelompok - mereka yang memiliki penargetan ulang dinonaktifkan dari semua saluran (1), sebagai kelompok eksperimen, dan mereka yang memiliki hanya penargetan ulang yang diaktifkan dengan Criteo (2); mereka yang hanya memiliki penargetan ulang dinonaktifkan dengan Criteo (3), mereka yang memiliki semua penargetan ulang (4) dihidupkan. Maka akan mungkin untuk menghitung (1) / (2), setelah menerima nilai aktual dari kanibalisasi kampanye iklan dari jaringan Criteo untuk transisi "organik" ke aplikasi, dan (3) / (4), setelah menerima kanibalisasi Criteo di lingkungan "alami" (setelah semua, Criteo, jelas dapat mengkanibal saluran berbayar lainnya juga). Eksperimen yang sama harus diulang untuk jaringan iklan lain untuk mengetahui dampak masing-masing; di dunia yang ideal, akan lebih baik untuk menyelidiki kanibalisasi silang antara semua sumber berbayar utama yang merupakan bagian terbesar dalam total lalu lintas, tetapi akan memakan banyak waktu (baik untuk menyiapkan eksperimen dari sudut pandang pengembangan dan untuk mengevaluasi hasil), yang akan menyebabkan kritik atas ketelitian yang tidak masuk akal.

Faktanya, percobaan kami dilakukan dalam kondisi (3) dan (4), sampel dibagi dalam rasio 10% hingga 90%, percobaan dilakukan selama 2 minggu.

Persiapan dan verifikasi data


Sebelum memulai studi apa pun, langkah penting adalah pra-pelatihan yang kompeten dan pembersihan data.

Perlu dicatat bahwa sebenarnya perangkat aktif untuk periode percobaan 2 kali lebih sedikit (masing-masing 42,5% dan 50% dari kelompok kontrol dan eksperimen) daripada perangkat dalam sampel awal lengkap, yang dijelaskan oleh sifat data:

  1. pertama (dan ini adalah alasan utama), pemilihan penargetan ulang dari Adjust berisi pengidentifikasi semua perangkat yang pernah menginstal aplikasi, yaitu, perangkat yang tidak lagi digunakan, dan yang sudah digunakan aplikasi tersebut. dihapus
  2. kedua, tidak perlu semua perangkat masuk ke aplikasi selama percobaan.

Namun, kami menghitung kanibalisasi berdasarkan data dari sampel lengkap. Bagi saya pribadi, kebenaran perhitungan seperti itu masih menjadi titik perdebatan - secara umum, menurut pendapat saya, lebih tepat untuk membersihkan semua orang yang menghapus instalasi aplikasi dan tidak menginstalnya dengan tag yang sesuai, serta mereka yang belum masuk ke aplikasi selama lebih dari setahun - periode waktu ini pengguna dapat mengubah perangkat; minus - dengan cara ini, untuk percobaan, pengguna yang tidak beralih ke aplikasi, tetapi dapat melakukannya, dapat dihapus dari pilihan jika kami menampilkan iklan di jaringan Criteo. Saya ingin mencatat bahwa di dunia yang baik semua pengabaian dan asumsi yang dipaksakan ini harus diselidiki dan diverifikasi secara terpisah, tetapi kita hidup di dunia yang melakukannya dengan cepat dan berbulu.

Dalam kasus kami, penting untuk memeriksa poin-poin berikut:

  1. Kami memeriksa persimpangan dalam sampel awal kami - eksperimental dan kontrol. Dalam percobaan yang diimplementasikan dengan benar, persimpangan tersebut tidak boleh, namun, dalam kasus kami, ada beberapa duplikat dari sampel eksperimental dalam kontrol. Dalam kasus kami, pangsa duplikat ini dalam volume total perangkat yang terlibat dalam percobaan kecil, oleh karena itu, kami mengabaikan kondisi ini. Jika ada> 1% duplikat, percobaan harus dianggap salah dan percobaan kedua harus dilakukan, setelah sebelumnya membersihkan duplikat.
  2. Kami memeriksa bahwa data dalam percobaan benar-benar terpengaruh - penargetan ulang seharusnya dinonaktifkan pada sampel eksperimen (setidaknya dengan Criteo, dalam percobaan yang ditetapkan dengan benar - dari semua saluran), oleh karena itu, perlu untuk memeriksa tidak adanya DeviceID dari percobaan dalam penargetan ulang dengan Criteo. Dalam kasus kami, DeviceID dari kelompok eksperimental jatuh ke retargeting, tetapi ada kurang dari 1%, yang dapat diabaikan.

Evaluasi langsung percobaan


Kami akan mempertimbangkan perubahan dalam metrik target berikut: absolut - jumlah panggilan, dan relatif - jumlah panggilan per pengguna dalam kontrol (melihat iklan di jaringan Criteo) dan grup eksperimental (iklan dinonaktifkan). Dalam kode di bawah ini, data variabel mengacu pada struktur panda.DataFrame, yang dibentuk dari hasil sampel eksperimental atau kontrol.

Ada metode parametrik dan nonparametrik untuk menilai signifikansi statistik dari perbedaan nilai dalam sampel yang tidak terkait. Kriteria evaluasi parametrik memberikan akurasi yang lebih besar, tetapi memiliki keterbatasan dalam penerapannya - khususnya, salah satu syarat utama adalah bahwa nilai yang terukur untuk pengamatan dalam sampel harus didistribusikan secara normal.

1. Studi tentang distribusi nilai dalam sampel untuk normalitas


Langkah pertama adalah memeriksa sampel yang ada untuk jenis distribusi nilai dan persamaan varians sampel menggunakan tes standar - kriteria Kolmogorov-Smirnov dan Shapiro-Wilks dan uji Bartlett diimplementasikan di perpustakaan sklearn.stats, mengambil p-value = 0,05:

#    : def norm_test(df, pvalue = 0.05, test_name = 'kstest'): if test_name == 'kstest': st = stats.kstest(df, 'norm') if test_name == 'shapiro': st = stats.shapiro(df) sys.stdout.write('According to {} {} is {}normal\n'.format(test_name, df.name, {True:'NOT ', False:''}[st[1] < pvalue])) #    : def barlett_test(df1, df2, pvalue = 0.05): st = stats.bartlett(df1, df2) sys.stdout.write('Variances of {} and {} is {}equals\n'.format(df1.name, df2.name, {True:'NOT ', False:''}[st[1] < pvalue])) 

Selain itu, untuk penilaian visual dari hasil, Anda dapat menggunakan fungsi histogram.

 data_agg = data.groupby(['bucket']).aggregate({'device_id': 'nunique', 'calls': 'sum'}).fillna(0) data_conv = data_agg['calls_auto']/data_agg['device_id'] data_conv.hist(bins=20) 

gambar

Anda dapat membaca histogram seperti ini: 10 kali dalam sampel ada konversi 0,08, 1 - 0,14. Ini tidak mengatakan apa-apa tentang jumlah perangkat sebagai pengamatan untuk salah satu indikator konversi.

Dalam kasus kami, distribusi nilai parameter baik dalam nilai absolut dan relatif (jumlah panggilan ke perangkat) dalam sampel tidak normal.
Dalam hal ini, Anda dapat menggunakan tes Wilcoxon nonparametrik yang diterapkan di perpustakaan standar sklearn.stats, atau mencoba membawa distribusi nilai dalam sampel ke bentuk normal dan menerapkan salah satu kriteria parametrik - Uji t alias siswa atau uji Shapiro-Wilks.

2. Metode mengurangi distribusi nilai dalam sampel ke bentuk normal


2.1. Sub-ember

Salah satu pendekatan untuk membawa distribusi menjadi normal adalah metode sub-bucket. Esensinya sederhana, dan tesis matematika berikut ini adalah dasar teoretis: menurut teorema limit pusat klasik, distribusi rata-rata cenderung normal - jumlah n variabel acak independen yang terdistribusi secara identik memiliki distribusi mendekati normal, dan, yang setara, distribusi sampel berarti dari yang pertama n independen yang identik secara acak jumlah cenderung normal. Oleh karena itu, kita dapat membagi bucket yang ada menjadi sub-bucket'y dan, dengan demikian, dengan mengambil nilai rata-rata sub-bucket'y untuk masing-masing bucket'ov, kami dapat memperoleh distribusi mendekati normal:

 #   subbucket' data['subbucket'] = data['device_id'].apply(lambda x: randint(0,1000)) # Variant 1 data['subbucket'] = data['device_id'].apply(lambda x: hash(x)%1000) # Variant 2 

Mungkin ada banyak opsi untuk pemisahan, semuanya tergantung pada imajinasi pengembang dan prinsip-prinsip moral - Anda dapat mengambil secara acak atau menggunakan hash dari keranjang asli, dengan demikian mempertimbangkan mekanisme untuk menerbitkannya dalam skema.

Namun, dalam praktiknya, dari beberapa lusin peluncuran kode, kami menerima distribusi normal hanya sekali, yaitu, metode ini tidak dijamin atau stabil.

Selain itu, rasio tindakan target dan pengguna dengan jumlah total tindakan dan pengguna di sub-bucket mungkin tidak konsisten dengan backet awal, jadi Anda harus terlebih dahulu memeriksa apakah rasio tersebut dipertahankan.

 data[data['calls'] > 0].device_id.nunique()/data.device_id.nunique() # Total buckets = data.groupby(['bucket']).aggregate({'device_id': 'nunique', 'calls': 'sum'}) buckets[buckets['calls'] > 0].device_id.nunique()/buckets.device_id.nunique() # Buckets subbuckets = data.groupby(['subbucket']).aggregate({'device_id': 'nunique', 'calls': 'sum'}) subbuckets[subbuckets['calls'] > 0].device_id.nunique()/subbuckets.device_id.nunique() # Subbuckets 

Dalam proses verifikasi tersebut, kami menemukan bahwa rasio konversi untuk subbucket relatif terhadap pemilihan asli tidak dipertahankan. Karena kita perlu juga menjamin konsistensi rasio pembagian panggilan dalam sampel keluaran dan sumber, kami menggunakan penyeimbangan kelas, menambahkan pembobotan sehingga data dipilih secara terpisah oleh subkelompok: terpisah dari pengamatan dengan tindakan target dan secara terpisah dari pengamatan tanpa tindakan target dalam proporsi yang tepat. Selain itu, dalam kasus kami, sampel didistribusikan secara tidak merata; secara intuitif, tampaknya rata-rata tidak boleh berubah, tetapi bagaimana ketidakseragaman sampel mempengaruhi varians tidak jelas dari rumus dispersi. Untuk memperjelas apakah perbedaan dalam ukuran sampel mempengaruhi hasil, kriteria Xi-square digunakan - jika perbedaan yang signifikan secara statistik terdeteksi, kerangka data yang lebih besar dengan ukuran yang lebih kecil akan dijadikan sampel:

 def class_arrays_balancer(df1, df2, target = 'calls', pvalue=0.05): df1_target_size = len(df1[df1[target] > 0]) print(df1.columns.to_list()) df2_target_size = len(df2[df2[target] > 0]) total_target_size = df1_target_size + df2_target_size chi2_target, pvalue_target, dof_target, expected_target = chi2_contingency([[df1_target_size, total_target_size], [df2_target_size, total_target_size]]) df1_other_size = len(df1[df1[target] == 0]) df2_other_size = len(df1[df1[target] == 0]) total_other_size = df1_other_size + df2_other_size chi2_other, pvalue_other, dof_other, expected_other = chi2_contingency([[df1_other_size, total_other_size], [df2_other_size, total_other_size]]) df1_target, df2_target, df1_other, df2_other = None, None, None, None if pvalue_target < pvalue: sample_size = min([df1_target_size, df2_target_size]) df1_rnd_indx = np.random.choice(df1_target_size, size=sample_size, replace=False) df2_rnd_indx = np.random.choice(df2_target_size, size=sample_size, replace=False) df1_target = pd.DataFrame((np.asarray(df1[df1[target] == 1])[df1_rnd_indx]).tolist(), columns = df1.columns.tolist()) df2_target = pd.DataFrame((np.asarray(df2[df2[target] == 1])[df2_rnd_indx]).tolist(), columns = df2.columns.tolist()) if p_value_other < pvalue: sample_size = min([df1_other_size, df2_other_size]) df1_rnd_indx = np.random.choice(df1_other_size, size=sample_size, replace=False) df2_rnd_indx = np.random.choice(df2_other_size, size=sample_size, replace=False) df1_other = pd.DataFrame((np.asarray(df1[df1[target] == 0])[df1_rnd_indx]).tolist(), columns = df1.columns.tolist()) df2_other = pd.DataFrame((np.asarray(df2[df2[target] == 0])[df2_rnd_indx]).tolist(), columns = df2.columns.tolist()) df1 = pd.concat([df1_target, df1_other]) df2 = pd.concat([df2_target, df2_other]) return df1, df2 exp_classes, control_classes = class_arrays_balancer(data_exp, data_control) 

Pada output, kami memperoleh data yang seimbang dalam ukuran dan konsisten dengan rasio konversi awal, metrik yang dipelajari (dihitung untuk nilai rata-rata untuk sub-ember) di mana mereka sudah didistribusikan secara normal, yang dapat dilihat baik secara visual maupun dengan hasil penerapan kriteria pengujian yang sudah diketahui oleh kami. normalitas (dengan p-value> = 0,05). Misalnya, untuk indikator relatif:

 data_conv = (data[data['calls'] > 0].groupby(['subbucket']).calls.sum()*1.0/data.groupby(['subbucket']).device_id.nunique()) data_conv.hist(bins = 50) 

Sekarang, uji-t dapat diterapkan pada rata-rata di atas sub-bucket (jadi, itu bukan device_id, bukan perangkat, tapi sub-bucket yang bertindak sebagai pengamatan).

Setelah memastikan bahwa perubahannya signifikan secara statistik, kami dapat, dengan hati nurani yang jelas, melakukan apa yang kami mulai semua - menghitung kanibalisasi:

 (data_exp.groupby(['subbucket']).calls.avg() - data_cntrl.groupby(['subbucket']).calls.avg() )/ data_exp.groupby(['subbucket']).calls.avg() 

Penyebutnya harus berupa lalu lintas tanpa iklan, yaitu eksperimental.

3. Metode Bootstrap


Metode bootstrap adalah perpanjangan dari metode sub-bucket dan mewakili versinya yang lebih maju dan lebih baik; implementasi perangkat lunak dari metode ini dengan Python dapat ditemukan di pustaka Bootstrap Facebook.
Secara singkat, ide bootstrap dapat dijelaskan sebagai berikut: suatu metode tidak lebih dari sebuah konstruktor sampel yang dihasilkan dengan cara yang mirip dengan metode sub-ember secara acak, tetapi dengan kemungkinan pengulangan. Kita dapat mengatakan penempatan dari populasi umum (jika seseorang dapat memanggil sampel asli) dengan pengembalian. Pada output, rata-rata (atau median, jumlah, dll.) Dibentuk dari rata-rata untuk masing-masing subsampel yang dihasilkan.

Metode utama perpustakaan FaceBook Bootstrap :
 bootstrap() 
- Menerapkan mekanisme untuk pembentukan sampel; mengembalikan batas bawah (5 persen) dan batas atas (95 persen) secara default; untuk mengembalikan distribusi diskrit dalam rentang ini, perlu untuk mengatur parameter return_distribution = True (dihasilkan oleh fungsi pembantu generate_distributions () ).

Anda dapat menentukan jumlah iterasi menggunakan parameter num_iterations , di mana subsampel akan dihasilkan, dan jumlah subsampel iteration_batch_size untuk setiap iterasi. Pada output dari generate_distributions () , sampel akan dihasilkan dengan ukuran yang sama dengan jumlah iterasi num_iterations , elemen-elemen yang akan menjadi rata-rata dari nilai-nilai sampel iteration_batch_size dihitung pada setiap iterasi. Dengan volume sampel yang besar, data mungkin tidak lagi sesuai dengan memori, sehingga dalam kasus seperti itu disarankan untuk mengurangi nilai iteration_batch_size .

Contoh : biarkan sampel asli menjadi 2.000.000; num_iterations = 10.000, iteration_batch_size = 300. Kemudian, di setiap 10.000 iterasi, 300 daftar 2.000.000 item akan disimpan dalam memori.

Fungsi ini juga memungkinkan komputasi paralel pada beberapa inti prosesor, pada beberapa utas, mengatur angka yang diperlukan menggunakan parameter num_threads .

 bootstrap_ab() 

melakukan semua tindakan yang sama seperti fungsi bootstrap () yang dijelaskan di atas, namun, selain itu, agregasi nilai rata-rata juga dilakukan oleh metode yang ditentukan dalam stat_func - dari nilai num_iterations . Selanjutnya, metrik yang ditentukan dalam parameter compare_func dihitung, dan signifikansi statistik diperkirakan.

 compare_functions 

- kelas fungsi yang menyediakan alat untuk pembentukan metrik untuk penilaian:
 compare_functions.difference() compare_functions.percent_change() compare_functions.ratio() compare_functions.percent_difference() # difference = (test_stat - ctrl_stat) # percent_change = (test_stat - ctrl_stat) * 100.0 / ctrl_stat # ratio = test_stat / ctrl_stat # percent_difference = (test_stat - ctrl_stat) / ((test_stat + ctrl_stat) / 2.0) * 100.0 

 stats_functions 
- kelas fungsi dari mana metode agregasi metrik yang dipelajari dipilih:
 stats_functions.mean stats_functions.sum stats_functions.median stats_functions.std 

Sebagai stat_func, Anda juga dapat menggunakan fungsi kustom yang ditentukan pengguna, misalnya:

 def test_func(test_stat, ctrl_stat): return (test_stat - ctrl_stat)/test_stat bs.bootstrap_ab(test.values, control.values, stats_functions.mean, test_func, num_iterations=5000, alpha=0.05, iteration_batch_size=100, scale_test_by=1, num_threads=4) 

Bahkan, (test_stat - ctrl_stat) / test_stat adalah rumus untuk menghitung kanibalisasi kita.

Sebagai alternatif, atau untuk tujuan percobaan praktis, Anda awalnya dapat memperoleh distribusi menggunakan bootstrap () , memeriksa signifikansi statistik perbedaan dalam metrik target menggunakan uji-t, dan kemudian menerapkan manipulasi yang diperlukan untuk mereka.
Contoh bagaimana distribusi normal "kualitas" dapat diperoleh dengan menggunakan metode ini:



Dokumentasi yang lebih terperinci dapat ditemukan di halaman repositori .

Saat ini, hanya itulah yang saya inginkan (atau berhasil) bicarakan. Saya mencoba menjelaskan secara singkat metode yang digunakan dan proses penerapannya. Ada kemungkinan bahwa metodologi membutuhkan penyesuaian, jadi saya akan berterima kasih atas umpan balik dan ulasan.

Saya juga ingin mengucapkan terima kasih kepada kolega saya atas bantuan mereka dalam mempersiapkan pekerjaan ini. Jika artikel tersebut menerima umpan balik yang sebagian besar positif, saya akan menunjukkan di sini nama atau nama panggilan mereka (dengan persetujuan sebelumnya).

Salam hangat untuk semuanya! :)

PS Dear Championship Channel , tugas mengevaluasi hasil pengujian A / B adalah salah satu yang paling penting dalam Ilmu Data, karena tidak satu pun peluncuran model-ML baru dalam produksi selesai tanpa A / B. Mungkin sudah waktunya untuk menyelenggarakan kompetisi untuk mengembangkan sistem untuk mengevaluasi hasil pengujian A / B? :)

Source: https://habr.com/ru/post/id451488/


All Articles