Dataset: Marketing A/B Testing (Kaggle)
Στόχος: Αξιολόγηση αποτελεσματικότητας διαφημιστικής καμπάνιας
## Warning: package 'tidyverse' was built under R version 4.5.3
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.2.0 ✔ readr 2.2.0
## ✔ forcats 1.0.1 ✔ stringr 1.6.0
## ✔ ggplot2 4.0.2 ✔ tibble 3.3.1
## ✔ lubridate 1.9.5 ✔ tidyr 1.3.2
## ✔ purrr 1.2.1
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
## Warning: package 'pwr' was built under R version 4.5.3
## Warning: package 'broom' was built under R version 4.5.3
## Warning: package 'scales' was built under R version 4.5.3
##
## Attaching package: 'scales'
##
## The following object is masked from 'package:purrr':
##
## discard
##
## The following object is masked from 'package:readr':
##
## col_factor
## Warning: package 'janitor' was built under R version 4.5.3
##
## Attaching package: 'janitor'
##
## The following objects are masked from 'package:stats':
##
## chisq.test, fisher.test
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`
## 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, …
n_control <- 8000 # μέγεθος ομάδας ελέγχου
n_treatment <- 8000 # μέγεθος πειραματικής ομάδας
p_control <- 0.08 # baseline conversion rate
p_treatment <- 0.10 # μετά την αλλαγή (true effect = +2%)Σχολιασμός:
Το dataset περιέχει 588.101 παρατηρήσεις και 7 μεταβλητές.
Μετά το clean_names() οι στήλες αποκτούν ονόματα σε
καθαρή μορφή. Δημιουργούμε τα “psa”(control group) και “ad” (treatment
group). Τέλος, μετατρέπουμε την στήλη converted σε 0/1 αντί
για boolean.
Πριν αναλύσουμε τα πραγματικά δεδομένα, φτιάχνουμε ένα τεχνητό πείραμα όπου γνωρίζουμε την αλήθεια (ground truth). Έτσι ώστε να επαληθεύσουμε ότι οι στατιστικές μέθοδοί λειτουργούν σωστά.
Ορίζουμε ένα ρεαλιστικό σενάριο fintech όπου το baseline conversion είναι 8% και η νέα διαφήμιση αναμένεται να το ανεβάσει στο 10%. Η διαφορά (+2 ποσοστιαίες μονάδες) είναι μικρή αλλά επιχειρηματικά σημαντική.
Δημιούργησε ένα tibble «experiment» με τις στήλες:
user_id (αύξων αριθμός 1 … n_control+n_treatment)
group (“control” ή “treatment”)
converted (0/1 — χρησιμοποίησε rbinom())
Hint: rbinom(n, 1, p) επιστρέφει vector από 0/1
experiment <- tibble(
user_id = 1:(n_control + n_treatment),
group = c(rep("control", n_control), rep("treatment", n_treatment)),
converted = c(
rbinom(n_control, size = 1, prob = p_control),
rbinom(n_treatment, size = 1, prob = p_treatment)
)
)
glimpse(experiment)## Rows: 16,000
## Columns: 3
## $ user_id <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 1…
## $ group <chr> "control", "control", "control", "control", "control", "cont…
## $ converted <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, …
Σχολιασμός:
Υπολόγισε ανά ομάδα:
Αποθήκευσε σε tibble «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),
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 643 0.0804 0.00304 0.0744 0.0863
## 2 treatment 8000 848 0.106 0.00344 0.0993 0.113
Σχολιασμός:
converted, ενώ το SE δίνεται από τον τύπο
SE=p(1−p)nSE = SE=np(1−p). Στη συνέχεια, χρησιμοποιούμε το 1.96 για να
κατασκευάσουμε το 95% confidence interval γύρω από το εκτιμώμενο
conversion rate και να συγκρίνουμε τις δύο ομάδες.Οπτικοποίησε με ggplot2 (geom_col + geom_errorbar)
Να φαίνεται καθαρά ποια ομάδα έχει υψηλότερο conversion rate
Χρησιμοποίησε scale_y_continuous(labels = percent)
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 = -0.8, fontface = "bold", size = 4
) +
scale_y_continuous(labels = percent, limits = c(0, 0.14)) +
scale_fill_manual(values = c("control" = "#7f8c8d", "treatment" = "#2ecc71")) +
labs(
title = "Conversion Rates ανά Ομάδα",
subtitle = "Οι μπάρες σφάλματος δείχνουν 95% Confidence Intervals",
x = "Ομάδα",
y = "Conversion Rate",
fill = "Ομάδα"
) +
theme_minimal(base_size = 13) +
theme(legend.position = "none")Σχολιασμός:
Διεξήγαγε έλεγχο υποθέσεων με prop.test()
Χρησιμοποίησε correct = FALSE (χωρίς Yates correction)
Αποθήκευσε το αποτέλεσμα σε «test_result»
test_result <- prop.test(
x = c(summary_stats$conversions[summary_stats$group == "treatment"],
summary_stats$conversions[summary_stats$group == "control"]),
n = c(summary_stats$n[summary_stats$group == "treatment"],
summary_stats$n[summary_stats$group == "control"]),
correct = FALSE # χωρίς Yates continuity correction
)
test_result##
## 2-sample test for equality of proportions without continuity correction
##
## data: c(summary_stats$conversions[summary_stats$group == "treatment"], summary_stats$conversions[summary_stats$group == "control"]) out of c(summary_stats$n[summary_stats$group == "treatment"], summary_stats$n[summary_stats$group == "control"])
## X-squared = 31.082, df = 1, p-value = 2.473e-08
## alternative hypothesis: two.sided
## 95 percent confidence interval:
## 0.01662519 0.03462481
## sample estimates:
## prop 1 prop 2
## 0.106000 0.080375
Σχολιασμός:
prop.test() συγκρίνει τα conversion rates των ομάδων
control και treatment μέσω χ² test (ισοδύναμο με z-test για
proportions). Με correct = FALSE απενεργοποιούμε τη Yates
correction, καθώς το δείγμα είναι μεγάλο. Εφόσον το p-value < 0.05,
απορριπτούμε τη H₀ και καταλήγουμε ότι υπάρχει στατιστικά σημαντική
διαφορά μεταξύ των ομάδων.Χειρωνακτική επαλήθευση — υπολόγισε:
(α) pooled estimate p̂_pool
(β) pooled SE
(γ) δ = p_treatment - p_control
(δ) 95% CI για τη διαφορά δ
Συγκρίνετε με το CI του prop.test()
# Εξάγουμε τα στατιστικά ανά ομάδα
p_ctrl <- summary_stats$conversion_rate[summary_stats$group == "control"]
p_trt <- summary_stats$conversion_rate[summary_stats$group == "treatment"]
n_ctrl <- summary_stats$n[summary_stats$group == "control"]
n_trt <- summary_stats$n[summary_stats$group == "treatment"]
# (α) Pooled estimate: συνδυάζουμε τις δύο ομάδες για τον έλεγχο
p_pool <- (p_ctrl * n_ctrl + p_trt * n_trt) / (n_ctrl + n_trt)
# (β) Pooled SE: χρησιμοποιείται για τον z-test
se_pool <- sqrt(p_pool * (1 - p_pool) * (1/n_ctrl + 1/n_trt))
# (γ) Διαφορά δ = p_treatment - p_control
delta <- p_trt - p_ctrl
# (δ) 95% CI για τη διαφορά (unpooled SE για CI)
se_diff <- sqrt(p_trt*(1-p_trt)/n_trt + p_ctrl*(1-p_ctrl)/n_ctrl)
ci_lower_diff <- delta - 1.96 * se_diff
ci_upper_diff <- delta + 1.96 * se_diff
# z-statistic και p-value
z_stat <- delta / se_pool
p_value <- 2 * pnorm(-abs(z_stat))## Χειρωνακτικοί Υπολογισμοί
## p_control = 0.0804
## p_treatment = 0.1060
## p_pool = 0.0932
## SE (pooled) = 0.004596
## δ (lift) = 0.0256 (+2.6%)
## 95% CI (δ) = [0.0166, 0.0346]
## z-statistic = 5.5751
## p-value = 0.000000
##
## CI από prop.test()
## 95% CI = [0.0166, 0.0346]
Σχολιασμός:
prop.test(). Αν το 0 δεν ανήκει στο CI, απορρίπτουμε
H₀.Power Analysis — υπολόγισε με pwr.2p.test()
Πόσο δείγμα χρειαζόταν πραγματικά για power = 80% και α = 0.05;
Hint: ES.h(p1 = 0.10, p2 = 0.08) δίνει το Cohen’s h
# Cohen's h: το effect size για διαφορές σε proportions
h <- ES.h(p1 = p_treatment, p2 = p_control)
# Υπολογισμός n, για power = 80%, α = 0.05
power_result <- pwr.2p.test(
h = h, # Cohen's h effect size
sig.level = 0.05, # α
power = 0.80, # 1 - β (επιθυμητή ισχύς)
alternative = "two.sided"
)
power_result##
## 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
##
## Cohen's h = 0.0700
## Απαιτούμενο n ανά ομάδα = 3205
## Τρέξαμε: 8000 ανά ομάδα
## Overpowered κατά: 149.6%
Σχολιασμός:
1.📊 Ποιο είναι το p-value του ελέγχου; Απορρίπτουμε την H₀ σε επίπεδο α = 0.05;
Το p-value είναι πολύ μικρότερο του 0.05, άρα απορρίπτουμε την H₀. Η διαφορά στα conversion rates δεν είναι τυχαία καθώς η treatment ομάδα μετατρέπει στατιστικά σημαντικά περισσότερο από την control.
2.🔎 Συμπίπτει το χειρωνακτικό CI με αυτό του prop.test(); Αν όχι, γιατί;
Τα CI είναι παρόμοια αλλά δεν ταυτίζονται ακριβώς. Το
prop.test() χρησιμοποιεί Wald-type CI βασισμένο στο χ² test
statistic, ενώ ο χειρωνακτικός υπολογισμός χρησιμοποιεί το unpooled SE
με z=1.96. Και οι δυο είναι έγκυρες προσεγγίσεις και οδηγούν στο ίδιο
συμπέρασμα.
3. 💡 Πόσα άτομα χρειαζόντουσαν για power 80%; Πόσα τρέξαμε; Τι συνεπάγεται αυτό;
Αν το απαιτούμενο είναι n < 8.000 τρέξαμε περισσότερους χρήστες από ό,τι χρειαζόταν, το πείραμα ήταν overpowered, άρα ακόμα πιο αξιόπιστο. Αν το απαιτούμενο είναι n > 8.000 το πείραμα ήταν underpowered (< 80% πιθανότητα να ανιχνεύσει την αληθινή διαφορά), και ακόμα κι αν βρούμε p < 0.05, ο τύπος Ι σφάλματος αυξάνεται σχετικά.
Εφαρμόζουμε τις ίδιες μεθόδους στα πραγματικά δεδομένα, όπου δεν γνωρίζουμε την αλήθεια. Επιπλέον, ελέγχουμε την ποιότητα της τυχαιοποίησης και εξετάζουμε αν το αποτέλεσμα διαφέρει ανά ημέρα εβδομάδας.
Invariants check — έλεγξε αν η τυχαιοποίηση δούλεψε
(α) Ποια η αναλογία ad/psa; Είναι 50/50;
(β) Οπτικοποίησε την κατανομή ανά ημέρα εβδομάδας (most_ads_day) για κάθε ομάδα — ψάξε ανισορροπίες
## # A tibble: 2 × 3
## group n pct
## <fct> <int> <dbl>
## 1 psa 23524 0.0400
## 2 ad 564577 0.960
Σχολιασμός:
day_order <- c("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
day_dist <- ads |>
mutate(most_ads_day = factor(most_ads_day, levels = day_order)) |>
count(group, most_ads_day) |>
group_by(group) |>
mutate(pct = n / sum(n))
ggplot(day_dist, aes(x = most_ads_day, y = pct, fill = group)) +
geom_col(position = "dodge", alpha = 0.85) +
scale_y_continuous(labels = percent) +
scale_fill_manual(values = c("psa" = "#7f8c8d", "ad" = "#3498db")) +
labs(
title = "Κατανομή Χρηστών ανά Ημέρα Εβδομάδας & Ομάδα",
subtitle = "Invariant check: παρόμοια κατανομή → τυχαιοποίηση OK",
x = "Ημέρα",
y = "Ποσοστό χρηστών",
fill = "Ομάδα"
) +
theme_minimal(base_size = 13) +
theme(axis.text.x = element_text(angle = 30, hjust = 1))Σχολιασμός:
Υπολόγισε conversion rate, SE, 95% CI ανά ομάδα:
real_stats <- ads |>
group_by(group) |>
summarise(
n = n(),
conversions = sum(converted),
conversion_rate = mean(converted),
se = sqrt(conversion_rate * (1 - conversion_rate) / n),
ci_lower = conversion_rate - 1.96 * se,
ci_upper = conversion_rate + 1.96 * se
)
real_stats## # A tibble: 2 × 7
## group n conversions conversion_rate se ci_lower ci_upper
## <fct> <int> <int> <dbl> <dbl> <dbl> <dbl>
## 1 psa 23524 420 0.0179 0.000863 0.0162 0.0195
## 2 ad 564577 14423 0.0255 0.000210 0.0251 0.0260
# prop.test() με broom::tidy() για μορφοποιημένο output
real_test <- prop.test(
x = c(real_stats$conversions[real_stats$group == "ad"],
real_stats$conversions[real_stats$group == "psa"]),
n = c(real_stats$n[real_stats$group == "ad"],
real_stats$n[real_stats$group == "psa"]),
correct = FALSE
)
tidy(real_test)## # A tibble: 1 × 9
## estimate1 estimate2 statistic p.value parameter conf.low conf.high method
## <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <chr>
## 1 0.0255 0.0179 54.3 1.71e-13 1 0.00595 0.00943 2-sample …
## # ℹ 1 more variable: alternative <chr>
Σχολιασμός:
broom::tidy() μετατρέπει το αποτέλεσμα του
prop.test() σε τακτοποιημένο tibble. Η στήλη
estimate1 είναι το conversion rate της “ad”,
estimate2 της “psa”. Αν p.value < 0.05 και το CI της
διαφοράς δεν περιέχει το 0, η διαφορά είναι στατιστικά σημαντική.Segmentation — conversion rate ανά ημέρα εβδομάδας:
Φτιάξε line plot με ribbon (95% CI) για κάθε ομάδα
Hint: geom_ribbon(aes(ymin = …, ymax = …), alpha = 0.15)
Ποια ημέρα έχει τη μεγαλύτερη διαφορά μεταξύ ομάδων;
day_conversion <- ads |>
mutate(most_ads_day = factor(most_ads_day, levels = day_order)) |>
group_by(group, most_ads_day) |>
summarise(
n = n(),
conversions = sum(converted),
conversion_rate = mean(converted),
se = sqrt(conversion_rate * (1 - conversion_rate) / n),
ci_lower = conversion_rate - 1.96 * se,
ci_upper = conversion_rate + 1.96 * se,
.groups = "drop"
)
# Βρίσκουμε την ημέρα με τη μεγαλύτερη διαφορά
max_diff_day <- day_conversion |>
select(group, most_ads_day, conversion_rate) |>
pivot_wider(names_from = group, values_from = conversion_rate) |>
mutate(diff = ad - psa) |>
slice_max(diff, n = 1)
ggplot(day_conversion, aes(x = most_ads_day, y = conversion_rate,
color = group, group = group)) +
geom_ribbon(aes(ymin = ci_lower, ymax = ci_upper, fill = group),
alpha = 0.15, color = NA) +
geom_line(linewidth = 1.2) +
geom_point(size = 3) +
annotate("text",
x = max_diff_day$most_ads_day,
y = max(day_conversion$ci_upper) + 0.002,
label = paste0("Max diff:\n", max_diff_day$most_ads_day),
size = 3.5, color = "darkred", fontface = "bold") +
scale_y_continuous(labels = percent) +
scale_color_manual(values = c("psa" = "#7f8c8d", "ad" = "#3498db")) +
scale_fill_manual(values = c("psa" = "#7f8c8d", "ad" = "#3498db")) +
labs(
title = "Conversion Rate ανά Ημέρα Εβδομάδας",
subtitle = "Ribbon = 95% Confidence Interval | Μεγαλύτερο gap → δυνατότερο αποτέλεσμα",
x = "Ημέρα Εβδομάδας",
y = "Conversion Rate",
color = "Ομάδα",
fill = "Ομάδα"
) +
theme_minimal(base_size = 13) +
theme(axis.text.x = element_text(angle = 30, hjust = 1))cat(sprintf("\nΗμέρα με μεγαλύτερη διαφορά: %s (diff = +%.3f%%)\n",
max_diff_day$most_ads_day, max_diff_day$diff * 100))##
## Ημέρα με μεγαλύτερη διαφορά: Tuesday (diff = +1.599%)
Σχολιασμός:
Επιχειρηματική απόφαση
Όρισε δ_min = 0.005 (κατώφλι επιχειρηματικής ουσίας)
Υπολόγισε το absolute lift και το relative lift
Ποια από τις 6 περιπτώσεις CI ισχύει; (A/B/C/D/E/F)
Ποια η τελική σύστασή σου;
delta_min <- 0.005 # κατώφλι επιχειρηματικής ουσίας (0.5%)
# Absolute lift: η απόλυτη αύξηση στο conversion rate
p_ad <- real_stats$conversion_rate[real_stats$group == "ad"]
p_psa <- real_stats$conversion_rate[real_stats$group == "psa"]
absolute_lift <- p_ad - p_psa
relative_lift <- absolute_lift / p_psa
# CI της διαφοράς (unpooled)
se_diff_real <- sqrt(p_ad*(1-p_ad)/real_stats$n[real_stats$group=="ad"] +
p_psa*(1-p_psa)/real_stats$n[real_stats$group=="psa"])
ci_diff_lower <- absolute_lift - 1.96 * se_diff_real
ci_diff_upper <- absolute_lift + 1.96 * se_diff_real## Επιχειρηματική Αξιολόγηση
## Conversion rate 'ad': 0.0255 (2.55%)
## Conversion rate 'psa': 0.0179 (1.79%)
## Absolute lift: +0.0077 (+0.77 pp)
## Relative lift: +43.1%
## 95% CI (διαφορά): [0.0060, 0.0094]
## Κατώφλι ουσίας (δ_min): 0.005
# Αξιολόγηση σεναρίου CI
p_val <- real_test$p.value
practically_significant <- absolute_lift > delta_min
statistically_significant <- p_val < 0.05
ci_above_threshold <- ci_diff_lower > delta_min## Κατάταξη Σεναρίου
## → Σενάριο A: Στατιστικά ΚΑΙ πρακτικά σημαντικό.Προχωράμε!
Το A/B test έδειξε στατιστικά σημαντική διαφορά στα conversion rates μεταξύ ad και psa ομάδας.
Το Μέρος Α επιβεβαίωσε ότι με n=8.000 ανά ομάδα και effect size +2pp, η στατιστική ισχύς είναι επαρκής.
Το invariants check αποκάλυψε ότι το dataset δεν είναι ισορροπημένο (ad >> psa), κάτι που πρέπει να λαμβάνεται υπόψη στην ερμηνεία.
Η segmentation ανά ημέρα δείχνει ότι η επίδραση της διαφήμισης δεν είναι ομοιόμορφη, κάποιες ημέρες αποδίδουν καλύτερα, παρέχοντας actionable insights για τη marketing ομάδα.
Η επιχειρηματική σύσταση εξαρτάται από το αν το lift υπερβαίνει το κατώφλι δ_min=0.005 και αν το ROI της καμπάνιας δικαιολογείται.