Ανάλυση αποτελεσματικότητας της νέας διαφημιστικής καμπάνιας για τη Fintech Startup μας. Στόχος είναι η επιβεβαίωση του Lift στις μετατροπές με στατιστική βεβαιότητα 95%.
Ως μέλος της ομάδας Data Analytics σε μια ταχέως αναπτυσσόμενη startup fintech, αναλάβαμε την αξιολόγηση μιας νέας διαφημιστικής καμπάνιας. Η ομάδα Marketing επιθυμεί να γνωρίζει αν η νέα διαφήμιση έχει πραγματικό και στατιστικά σημαντικό αντίκτυπο στις μετατροπές (conversions) των χρηστών, ή αν τυχόν διαφορές οφείλονται σε καθαρή τύχη.
Για τον σκοπό αυτό, διενεργήθηκε ένα A/B Test:
Το σύνολο δεδομένων marketing_AB.csv προέρχεται από το
Kaggle και περιλαμβάνει περίπου 588.000 παρατηρήσεις. Οι βασικές
μεταβλητές που θα μας απασχολήσουν είναι:
user_id: Ο μοναδικός αναγνωριστικός αριθμός του
χρήστη.test_group: Η ομάδα στην οποία ανήκει ο χρήστης
(ad ή psa).converted: Δυαδική μεταβλητή (0/1 ή False/True) που
υποδεικνύει αν ο χρήστης προχώρησε σε αγορά/μετατροπή.most_ads_day: Η ημέρα της εβδομάδας που ο χρήστης είδε
τις περισσότερες διαφημίσεις.Στόχος μας είναι να αναλύσουμε τα δεδομένα με στατιστική αυστηρότητα και να εξάγουμε τεκμηριωμένες προτάσεις.
Αρχικά, φορτώνουμε τα απαραίτητα πακέτα και το πραγματικό dataset
marketing_AB.csv.
# Φόρτωση πακέτων
library(tidyverse)
library(pwr)
library(broom)
library(scales)
library(janitor)
# Για αναπαραγωγιμότητα
set.seed(40)
# Φόρτωση πραγματικών δεδομένων (Μέρος Β)
ads <- read_csv("marketing_AB.csv") |>
janitor::clean_names() |>
mutate(
group = factor(test_group, levels = c("psa", "ad")),
converted = as.integer(converted)
)
## New names:
## Rows: 588101 Columns: 7
## ── Column specification
## ──────────────────────────────────────────────────────── Delimiter: "," chr
## (2): test group, most ads day dbl (4): ...1, user id, total ads, most ads hour
## lgl (1): converted
## ℹ Use `spec()` to retrieve the full column specification for this data. ℹ
## Specify the column types or set `show_col_types = FALSE` to quiet this message.
## • `` -> `...1`
glimpse(ads)
## Rows: 588,101
## Columns: 8
## $ x1 <dbl> 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16…
## $ user_id <dbl> 1069124, 1119715, 1144181, 1435133, 1015700, 1137664, 11…
## $ test_group <chr> "ad", "ad", "ad", "ad", "ad", "ad", "ad", "ad", "ad", "a…
## $ converted <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0,…
## $ total_ads <dbl> 130, 93, 21, 355, 276, 734, 264, 17, 21, 142, 209, 47, 6…
## $ most_ads_day <chr> "Monday", "Tuesday", "Tuesday", "Tuesday", "Friday", "Sa…
## $ most_ads_hour <dbl> 20, 22, 18, 10, 14, 10, 13, 18, 19, 14, 11, 13, 20, 13, …
## $ group <fct> ad, ad, ad, ad, ad, ad, ad, ad, ad, ad, ad, ad, ad, ad, …
Πριν προχωρήσουμε στα πραγματικά δεδομένα, είναι χρήσιμο να στήσουμε μια προσομοίωση (simulation). Σκοπός είναι να ελέγξουμε αν τα στατιστικά μας εργαλεία μπορούν να εντοπίσουν σωστά μια διαφορά (lift), όταν εμείς οι ίδιοι γνωρίζουμε εκ των προτέρων ποια είναι η «αλήθεια» (γνωρίζουμε δηλαδή ότι υπάρχει πραγματική αύξηση +2%).
Δημιουργούμε 16.000 εικονικούς χρήστες, μοιρασμένους 50-50, και ορίζουμε baseline conversion 8% και treatment conversion 10%. Στη συνέχεια υπολογίζουμε τα βασικά περιγραφικά στατιστικά (n, μετατροπές, conversion rate, Standard Error, 95% Διαστήματα Εμπιστοσύνης).
# Παράμετροι πειράματος
n_control <- 8000
n_treatment <- 8000
p_control <- 0.08
p_treatment <- 0.10
# TODO 1: Δημιουργία tibble experiment
experiment <- tibble(
user_id = 1:(n_control + n_treatment),
group = c(rep("control", n_control), rep("treatment", n_treatment)),
converted = c(rbinom(n_control, 1, p_control), rbinom(n_treatment, 1, p_treatment))
)
# TODO 2: Υπολογισμός summary_stats
summary_stats <- experiment |>
group_by(group) |>
summarise(
n = n(),
conversions = sum(converted),
conversion_rate = mean(converted),
se = sqrt(conversion_rate * (1 - conversion_rate) / n)
) |>
mutate(
ci_lower = conversion_rate - 1.96 * se,
ci_upper = conversion_rate + 1.96 * se
)
summary_stats
## # A tibble: 2 × 7
## group n conversions conversion_rate se ci_lower ci_upper
## <chr> <int> <int> <dbl> <dbl> <dbl> <dbl>
## 1 control 8000 651 0.0814 0.00306 0.0754 0.0874
## 2 treatment 8000 855 0.107 0.00345 0.100 0.114
Σχόλιο: Όπως παρατηρούμε, τα estimated conversion rates είναι πολύ κοντά στις παραμέτρους (0.08 και 0.10) που θέσαμε εμείς.
Οπτικοποιούμε τα ποσοστά μετατροπής μαζί με τα 95% Διαστήματα Εμπιστοσύνης. Αν τα error bars (οι κάθετες γραμμές) δεν επικαλύπτονται, αποτελεί μια ισχυρή οπτική ένδειξη στατιστικής σημαντικότητας.
ggplot(summary_stats, aes(x = group, y = conversion_rate, fill = group)) +
geom_col(width = 0.5, alpha = 0.85) +
geom_errorbar(aes(ymin = ci_lower, ymax = ci_upper), width = 0.15, linewidth = 0.8) +
geom_text(aes(label = percent(conversion_rate, accuracy = 0.1)), vjust = -1.5, fontface = "bold") +
scale_y_continuous(labels = percent, limits = c(0, 0.12)) +
scale_fill_manual(values = c("control" = "#6b7280", "treatment" = "#3b82f6")) +
labs(
title = "Simulated A/B Test: Conversion Rate ανά ομάδα",
subtitle = "Με 95% διαστήματα εμπιστοσύνης",
x = NULL, y = "Conversion Rate"
) +
theme_minimal(base_size = 13) +
theme(legend.position = "none")
Εκτελούμε έναν έλεγχο αναλογιών (2-proportion z-test) για να δούμε αν η διαφορά είναι στατιστικά σημαντική. Στη συνέχεια, εφαρμόζουμε τους μαθηματικούς τύπους με το χέρι για να επαληθεύσουμε τον αλγόριθμο της R.
# TODO 4: Έλεγχος Υποθέσεων με prop.test()
test_result <- prop.test(
x = summary_stats$conversions,
n = summary_stats$n,
correct = FALSE # Απενεργοποίηση της διόρθωσης Yates
)
test_result
##
## 2-sample test for equality of proportions without continuity correction
##
## data: summary_stats$conversions out of summary_stats$n
## X-squared = 30.505, df = 1, p-value = 3.331e-08
## alternative hypothesis: two.sided
## 95 percent confidence interval:
## -0.03454046 -0.01645954
## sample estimates:
## prop 1 prop 2
## 0.081375 0.106875
# TODO 5: Χειρωνακτική επαλήθευση (Pooled estimate)
p_pool <- sum(summary_stats$conversions) / sum(summary_stats$n)
se_pool <- sqrt(p_pool * (1 - p_pool) * (1/summary_stats$n[1] + 1/summary_stats$n[2]))
delta <- summary_stats$conversion_rate[2] - summary_stats$conversion_rate[1]
m <- 1.96 * se_pool
cat(sprintf("Pooled p: %.4f\n", p_pool))
## Pooled p: 0.0941
cat(sprintf("Pooled SE: %.4f\n", se_pool))
## Pooled SE: 0.0046
cat(sprintf("Διαφορά (Delta): %.4f\n", delta))
## Διαφορά (Delta): 0.0255
cat(sprintf("95%% CI για το Delta: [%.4f, %.4f]\n", delta - m, delta + m))
## 95% CI για το Delta: [0.0165, 0.0345]
Σχόλιο: Το p-value του ελέγχου είναι εξαιρετικά μικρό 0.00000003331 (πολύ κάτω από 0.05), επιβεβαιώνοντας στατιστικά αυτό που γνωρίζαμε: η θεραπεία όντως δούλεψε.
Πόσα δείγματα πραγματικά χρειαζόμασταν για να είμαστε 80% σίγουροι ότι θα εντοπίζαμε αυτή την αλλαγή του 2%;
# Υπολογισμός του μεγέθους της επίδρασης (Cohen's h)
effect_size <- ES.h(p1 = 0.10, p2 = 0.08)
# TODO 6: Υπολογισμός απαιτούμενου δείγματος
sample_size_calc <- pwr.2p.test(
h = effect_size,
sig.level = 0.05,
power = 0.80,
alternative = "two.sided"
)
sample_size_calc
##
## Difference of proportion power calculation for binomial distribution (arcsine transformation)
##
## h = 0.069988
## n = 3204.715
## sig.level = 0.05
## power = 0.8
## alternative = two.sided
##
## NOTE: same sample sizes
Σχόλιο: Το αποτέλεσμα μας δείχνει ότι απαιτούνταν περίπου 3205 παρατηρήσεις ανά ομάδα. Στην προσομοίωσή μας είχαμε 8.000 ανά ομάδα, άρα το πείραμά μας ήταν επαρκώς τροφοδοτημένο (well-powered).
Τώρα που ελέγξαμε τη μεθοδολογία μας, εφαρμόζουμε την ανάλυση στο πραγματικό dataset με τις 588.000 εγγραφές.
Ελέγχουμε αν η κατανομή των χρηστών είναι όντως 50/50 και αν η επισκεψιμότητα ανά ημέρα είναι ισορροπημένη ανάμεσα στις δύο ομάδες.
# α) Αναλογία ad/psa
ads |>
count(group) |>
mutate(pct = n / sum(n))
## # A tibble: 2 × 3
## group n pct
## <fct> <int> <dbl>
## 1 psa 23524 0.0400
## 2 ad 564577 0.960
# β) Οπτικοποίηση κατανομής ανά ημέρα
ads |>
count(group, most_ads_day) |>
group_by(group) |>
mutate(pct = n / sum(n)) |>
ggplot(aes(x = most_ads_day, y = pct, fill = group)) +
geom_col(position = "dodge") +
scale_y_continuous(labels = percent) +
labs(title = "Invariant check: Κατανομή ανά ημέρα", x = NULL, y = "% της ομάδας") +
theme_minimal()
Σχόλιο: Παρατηρούμε μια εντυπωσιακή
ανισορροπία: Το 96% των χρηστών μπήκε στην ομάδα
ad και μόλις το ~4% στην ομάδα psa (control).
Αυτό είναι συχνό σε εταιρικά περιβάλλοντα όταν θέλουν να λανσάρουν το
προϊόν στο ευρύ κοινό κρατώντας απλώς ένα μικρό δείγμα για επιβεβαίωση,
χωρίς να χάσουν δυνητικά έσοδα. Η κατανομή των ημερών φαίνεται ωστόσο
ομοιόμορφη μεταξύ των δύο ομάδων, συνεπώς η τυχαιοποίηση ανά ημέρα
δούλεψε.
Εκτελούμε τον έλεγχο του πειράματος χρησιμοποιώντας το πραγματικό
conversion_rate.
# Υπολογισμός summary stats
ads_summary <- ads |>
group_by(group) |>
summarise(
n = n(),
conversions = sum(converted),
conversion_rate = mean(converted),
se = sqrt(conversion_rate * (1 - conversion_rate) / n)
) |>
mutate(
ci_lower = conversion_rate - 1.96 * se,
ci_upper = conversion_rate + 1.96 * se
)
# Z-test σε πραγματικά δεδομένα
test_real <- prop.test(
x = ads_summary$conversions,
n = ads_summary$n,
correct = FALSE
)
broom::tidy(test_real) |>
select(estimate1, estimate2, statistic, p.value, conf.low, conf.high)
## # A tibble: 1 × 6
## estimate1 estimate2 statistic p.value conf.low conf.high
## <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 0.0179 0.0255 54.3 1.71e-13 -0.00943 -0.00595
Σχόλιο: Το test δείχνει ότι η ομάδα του “ad”
(estimate2) έχει υψηλότερο ποσοστό μετατροπής από την ομάδα
του “psa” (estimate1). Το p-value είναι πρακτικά 0, που
υποδηλώνει απόλυτη στατιστική σημαντικότητα.
Έχει σημασία η μέρα που προβλήθηκε η διαφήμιση; Πάμε να το ελέγξουμε χωρίζοντας (segmenting) τα δεδομένα ανά ημέρα.
# Υπολογισμοί ανά ημέρα και ομάδα
ads_by_day <- ads |>
group_by(most_ads_day, group) |>
summarise(
n = n(),
conversion_rate = mean(converted),
se = sqrt(conversion_rate * (1 - conversion_rate) / n),
.groups = "drop"
)
# Οπτικοποίηση Segmentation
ggplot(ads_by_day, aes(x = most_ads_day, y = conversion_rate, color = group, group = group)) +
geom_line(linewidth = 1.2) +
geom_point(size = 3) +
geom_ribbon(aes(ymin = conversion_rate - 1.96 * se, ymax = conversion_rate + 1.96 * se, fill = group), alpha = 0.15, color = NA) +
scale_y_continuous(labels = percent_format(accuracy = 0.1)) +
scale_color_manual(values = c("psa" = "#6b7280", "ad" = "#3b82f6")) +
scale_fill_manual(values = c("psa" = "#6b7280", "ad" = "#3b82f6")) +
labs(
title = "Conversion rate ανά ημέρα εβδομάδας",
subtitle = "Με 95% διαστήματα εμπιστοσύνης",
x = NULL, y = "Conversion rate"
) +
theme_minimal(base_size = 13)
Σχόλιο: Παρατηρούμε ότι καθ’ όλη τη διάρκεια της εβδομάδας, η ομάδα “ad” υπερέχει. Τη Δευτέρα και την Τρίτη φαίνεται να παρατηρούνται οι μεγαλύτερες διαφορές (οπτικά μεγαλύτερη απόσταση μεταξύ των δύο γραμμών).
Η στατιστική σημαντικότητα δεν ταυτίζεται πάντα με την πρακτική/επιχειρηματική σημαντικότητα. Αν ορίσουμε το ελάχιστο αποδεκτό επιχειρηματικό κέρδος στο 0.5% (δ_min = 0.005), αξίζει τον κόπο η καμπάνια;
conf_int <- broom::tidy(test_real)
# Στο prop.test() estimate1 είναι το "psa" και estimate2 το "ad".
# Υπολογίζουμε Absolute και Relative lift με βάση το control (psa).
estimate_psa <- conf_int$estimate1
estimate_ad <- conf_int$estimate2
abs_lift <- (estimate_ad - estimate_psa) * 100
rel_lift <- (estimate_ad - estimate_psa) / estimate_psa * 100
cat(sprintf("Absolute lift: %.2f ποσοστιαίες μονάδες\n", abs_lift))
## Absolute lift: 0.77 ποσοστιαίες μονάδες
cat(sprintf("Relative lift: %+.1f%%\n", rel_lift))
## Relative lift: +43.1%
# Διάστημα Εμπιστοσύνης (Επειδή το test αφαίρεσε x1-x2 δηλ. psa - ad, αντιστρέφουμε τα πρόσημα για ad - psa)
ci_lower_diff <- -conf_int$conf.high
ci_upper_diff <- -conf_int$conf.low
cat(sprintf("95%% CI για το Lift (Ad - PSA): [%.5f, %.5f]\n", ci_lower_diff, ci_upper_diff))
## 95% CI για το Lift (Ad - PSA): [0.00595, 0.00943]
Σχόλιο Απόφασης: Το κατώτερο όριο του διαστήματος
εμπιστοσύνης είναι περίπου 0.0060 (0.60%). Αυτό σημαίνει
ότι είμαστε 95% βέβαιοι ότι η διαφήμιση φέρνει τουλάχιστον
0.60% absolute lift. Αφού 0.0060 > δ_min (0.005),
βρισκόμαστε στην ευνοϊκότερη περίπτωση: Το κάτω όριο είναι πάνω από το
επιχειρηματικό όριο.
Βάσει της ανάλυσης, η διαφορά είναι στατιστικά σημαντική και υπερβαίνει το κατώφλι του 0.5%. Προτείνουμε το πλήρες λανσάρισμα (Rollout) της καμπάνιας σε όλους τους χρήστες.
Ποιο είναι το p-value του ελέγχου; Απορρίπτουμε την H₀ σε
επίπεδο α = 0.05; Το p-value (όπως φάνηκε από την εντολή
tidy) είναι μικρότερο από 2.2e-16, δηλαδή
πρακτικά μηδέν. Συνεπώς απορρίπτουμε την μηδενική υπόθεση H₀ σε
οποιοδήποτε λογικό επίπεδο σημαντικότητας (σίγουρα για α = 0.05).
Υπάρχει στατιστικά σημαντική επίδραση της διαφήμισης υπέρ των
μετατροπών.
Συμπίπτει το χειρωνακτικό CI με αυτό του prop.test(); Αν
όχι, γιατί; Ναι, στο Μέρος Α το χειρωνακτικό CI ταυτίζεται
απόλυτα με αυτό του prop.test(). Ο λόγος είναι ότι ρητά
θέσαμε την παράμετρο correct = FALSE στη συνάρτηση,
απενεργοποιώντας έτσι τη διόρθωση συνέχειας του Yates. Αν αφήναμε τη
default ρύθμιση (correct = TRUE), τα διαστήματα θα διέφεραν
ελαφρώς.
Πόσα άτομα χρειαζόντουσαν για power 80%; Πόσα τρέξαμε; Τι συνεπάγεται αυτό; Όπως απέδειξε το Power Analysis, χρειαζόμασταν μόλις γύρω στις 3205 παρατηρήσεις ανά ομάδα για να έχουμε ισχύ 80% στην ανίχνευση του συγκεκριμένου Effect Size. Στην προσομοίωση τρέξαμε 8.000/ομάδα και στα πραγματικά δεδομένα πάνω από 500.000. Αυτό συνεπάγεται ότι έχουμε υπερ-επαρκές δείγμα (Over-powered test). Οι ελάχιστες διαφορές γίνονται άμεσα αντιληπτές και η πιθανότητα ενός Σφάλματος Τύπου ΙΙ (να υπάρχει πραγματική διαφορά αλλά το τεστ μας να μην την “πιάσει”) είναι πρακτικά μηδενική.