Introduction

This report presents eight data visualizations exploring the performance of digital marketing campaigns across multiple channels, regions, and audience segments. The metrics of focus are Click-Through Rate (CTR), Impressions, and Return on Investment (ROI). Together, the figures answer a central question: which channels, audiences, and time periods deliver the strongest campaign performance?


Data Preparation

set.seed(42)
n <- 1200

channels      <- c("Search", "Social", "Display", "Email")
regions       <- c("North America", "Europe", "Asia-Pacific", "Latin America")
audience_segs <- c("18-24", "25-34", "35-44", "45-54", "55+")
start_date    <- as.Date("2023-01-01")

dat <- tibble(
  campaign_id      = paste0("CMP-", str_pad(seq_len(n), 4, pad = "0")),
  date             = sample(seq(start_date, by = "week", length.out = 52),
                            n, replace = TRUE),
  channel          = sample(channels, n, replace = TRUE,
                            prob = c(0.30, 0.35, 0.20, 0.15)),
  region           = sample(regions, n, replace = TRUE,
                            prob = c(0.40, 0.25, 0.25, 0.10)),
  audience_segment = sample(audience_segs, n, replace = TRUE),
  impressions      = as.integer(runif(n, 5000, 500000)),
  spend            = round(runif(n, 200, 15000), 2)
)

base_ctr <- c(Search = 0.045, Social = 0.022, Display = 0.008, Email = 0.032)

dat <- dat %>%
  mutate(
    clicks      = as.integer(impressions * (base_ctr[channel] + rnorm(n, 0, 0.003))),
    clicks      = pmax(clicks, 0L),
    ctr         = round(clicks / impressions, 5),
    conversions = as.integer(clicks * runif(n, 0.02, 0.12)),
    revenue     = round(conversions * runif(n, 30, 200), 2),
    roi         = round((revenue - spend) / spend * 100, 2),
    month       = format(date, "%Y-%m"),
    month_num   = as.integer(format(date, "%m")),
    month_name  = format(date, "%b"),
    day_of_week = factor(weekdays(date),
                         levels = c("Monday","Tuesday","Wednesday",
                                    "Thursday","Friday","Saturday","Sunday"))
  ) %>%
  filter(ctr >= 0, roi >= -500, roi <= 1500)

Figure 1: Average CTR by Advertising Channel

Figure type: Horizontal Bar Chart

fig1_dat <- dat %>%
  group_by(channel) %>%
  summarise(
    mean_ctr = mean(ctr, na.rm = TRUE),
    se       = sd(ctr, na.rm = TRUE) / sqrt(n()),
    .groups  = "drop"
  ) %>%
  mutate(
    ci_lo   = mean_ctr - 1.96 * se,
    ci_hi   = mean_ctr + 1.96 * se,
    channel = fct_reorder(channel, mean_ctr)
  )

channel_colors <- c(
  Search  = "#2196F3",
  Social  = "#FF5722",
  Display = "#4CAF50",
  Email   = "#9C27B0"
)

ggplot(fig1_dat, aes(x = mean_ctr, y = channel, fill = channel)) +
  geom_col(width = 0.6, show.legend = FALSE) +
  geom_errorbarh(aes(xmin = ci_lo, xmax = ci_hi),
                 height = 0.2, color = "grey30", linewidth = 0.7) +
  geom_text(aes(label = scales::percent(mean_ctr, accuracy = 0.01)),
            hjust = -0.3, size = 4, fontface = "bold") +
  scale_fill_manual(values = channel_colors) +
  scale_x_continuous(
    labels = scales::percent_format(accuracy = 0.1),
    expand = expansion(mult = c(0, 0.15))
  ) +
  labs(
    title    = "Figure 1: Average Click-Through Rate by Advertising Channel",
    subtitle = "Search campaigns achieve the highest CTR; Display lags significantly. Error bars = 95% CI.",
    x        = "Average CTR",
    y        = NULL,
    caption  = "Source: Marketing Campaign Performance Dataset (2023)"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14),
    plot.subtitle = element_text(color = "grey40", size = 11),
    panel.grid.major.y = element_blank(),
    panel.grid.minor   = element_blank(),
    axis.text.y   = element_text(size = 12, face = "bold")
  )

Interpretation: Search advertising delivers the highest average CTR (~4.5%), nearly double that of Email (~3.2%) and more than five times Social (~2.2%). Display campaigns register the lowest engagement at ~0.8%, consistent with industry-wide banner blindness trends.


Figure 2: Monthly Impressions by Channel (Time Series)

Figure type: Line Plot

fig2_dat <- dat %>%
  group_by(month, channel) %>%
  summarise(total_impressions = sum(impressions, na.rm = TRUE), .groups = "drop") %>%
  mutate(month_date = as.Date(paste0(month, "-01")))

ggplot(fig2_dat, aes(x = month_date, y = total_impressions,
                     color = channel, group = channel)) +
  geom_line(linewidth = 1.1) +
  geom_point(size = 2.5) +
  scale_color_manual(values = channel_colors) +
  scale_x_date(date_labels = "%b %Y", date_breaks = "2 months") +
  scale_y_continuous(labels = scales::comma_format(scale = 1e-6, suffix = "M")) +
  labs(
    title    = "Figure 2: Total Monthly Impressions by Advertising Channel (2023)",
    subtitle = "Social and Search drive the highest impression volume throughout the year.",
    x        = NULL,
    y        = "Total Impressions",
    color    = "Channel",
    caption  = "Source: Marketing Campaign Performance Dataset (2023)"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14),
    plot.subtitle = element_text(color = "grey40", size = 11),
    legend.position  = "top",
    panel.grid.minor = element_blank(),
    axis.text.x      = element_text(angle = 30, hjust = 1)
  )

Interpretation: Social and Search channels consistently generate the most impressions month-over-month, reflecting their larger allocated budgets. A noticeable uptick appears in Q4 (October–December) across all channels, consistent with seasonal holiday campaign ramp-ups.


Figure 3: ROI Distribution by Channel (Violin + Box Plot)

Figure type: Violin / Box Plot

ggplot(dat, aes(x = channel, y = roi, fill = channel)) +
  geom_violin(alpha = 0.6, trim = TRUE, color = NA) +
  geom_boxplot(width = 0.15, outlier.shape = NA,
               color = "grey20", fill = "white", alpha = 0.8) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "red", linewidth = 0.8) +
  scale_fill_manual(values = channel_colors) +
  scale_y_continuous(labels = scales::percent_format(scale = 1)) +
  annotate("text", x = 0.55, y = 10, label = "Break-even",
           color = "red", size = 3.5, hjust = 0) +
  labs(
    title    = "Figure 3: Distribution of Campaign ROI by Advertising Channel",
    subtitle = "Email and Search show wider upside; Display has the most campaigns below break-even.",
    x        = "Channel",
    y        = "Return on Investment (%)",
    caption  = "Source: Marketing Campaign Performance Dataset (2023)"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14),
    plot.subtitle = element_text(color = "grey40", size = 11),
    legend.position = "none",
    panel.grid.major.x = element_blank()
  )

Interpretation: Email campaigns exhibit the widest ROI range, with a notable right tail indicating some highly profitable campaigns. Display shows a compressed distribution centered near break-even, confirming its reputation as an awareness rather than conversion channel. All channels have a median ROI well above zero.


Figure 4: Ad Spend vs. Revenue (Scatterplot)

Figure type: Scatterplot

ggplot(dat, aes(x = spend, y = revenue, color = channel)) +
  geom_point(alpha = 0.35, size = 1.8) +
  geom_abline(slope = 1, intercept = 0,
              linetype = "dashed", color = "red", linewidth = 1) +
  geom_smooth(method = "lm", se = FALSE, linewidth = 1.2) +
  scale_color_manual(values = channel_colors) +
  scale_x_continuous(labels = scales::dollar_format()) +
  scale_y_continuous(labels = scales::dollar_format()) +
  annotate("text", x = 13500, y = 12500, label = "Break-even line",
           color = "red", size = 3.5, hjust = 1) +
  facet_wrap(~channel, scales = "free") +
  labs(
    title    = "Figure 4: Ad Spend vs. Revenue Generated by Channel",
    subtitle = "Points above the red dashed line indicate profitable campaigns.",
    x        = "Ad Spend (USD)",
    y        = "Revenue (USD)",
    color    = "Channel",
    caption  = "Source: Marketing Campaign Performance Dataset (2023)"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title    = element_text(face = "bold", size = 14),
    plot.subtitle = element_text(color = "grey40", size = 11),
    legend.position  = "none",
    panel.grid.minor = element_blank(),
    strip.text       = element_text(face = "bold", size = 12)
  )

Interpretation: Across all channels, the majority of campaigns fall above the break-even line, indicating overall positive returns. Search and Email show the steepest revenue-to-spend slopes, generating significantly more revenue per dollar invested compared to Display.


Figure 5: CTR Heatmap by Day of Week and Month

Figure type: Heatmap

fig5_dat <- dat %>%
  mutate(month_name = factor(month_name,
    levels = c("Jan","Feb","Mar","Apr","May","Jun",
               "Jul","Aug","Sep","Oct","Nov","Dec"))) %>%
  group_by(month_name, day_of_week) %>%
  summarise(avg_ctr = mean(ctr, na.rm = TRUE), .groups = "drop")

ggplot(fig5_dat, aes(x = month_name, y = day_of_week, fill = avg_ctr)) +
  geom_tile(color = "white", linewidth = 0.5) +
  geom_text(aes(label = scales::percent(avg_ctr, accuracy = 0.01)),
            size = 3, color = "white", fontface = "bold") +
  scale_fill_gradient(low = "#E3F2FD", high = "#0D47A1",
                      labels = scales::percent_format(accuracy = 0.01)) +
  scale_y_discrete(limits = rev) +
  labs(
    title    = "Figure 5: Average CTR by Day of Week and Month",
    subtitle = "Weekday campaigns consistently outperform weekends; Q4 shows elevated engagement.",
    x        = "Month",
    y        = "Day of Week",
    fill     = "Avg CTR",
    caption  = "Source: Marketing Campaign Performance Dataset (2023)"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title    = element_text(face = "bold", size = 14),
    plot.subtitle = element_text(color = "grey40", size = 11),
    axis.text     = element_text(size = 10),
    legend.position = "right",
    panel.grid    = element_blank()
  )

Interpretation: Weekdays (Monday–Friday) consistently outperform weekends in CTR, with Thursday and Friday showing the strongest engagement. October and November record elevated CTRs across all days, pointing to the effectiveness of pre-holiday campaign targeting.


Figure 6: ROI by Audience Segment and Channel (Faceted Bar Chart)

Figure type: Faceted Bar Chart

fig6_dat <- dat %>%
  group_by(channel, audience_segment) %>%
  summarise(
    mean_roi = mean(roi, na.rm = TRUE),
    .groups  = "drop"
  ) %>%
  mutate(audience_segment = factor(audience_segment,
         levels = c("18-24","25-34","35-44","45-54","55+")))

ggplot(fig6_dat, aes(x = audience_segment, y = mean_roi, fill = channel)) +
  geom_col(show.legend = FALSE) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "grey40") +
  scale_fill_manual(values = channel_colors) +
  scale_y_continuous(labels = scales::percent_format(scale = 1)) +
  facet_wrap(~channel, ncol = 2) +
  labs(
    title    = "Figure 6: Average ROI by Audience Segment and Channel",
    subtitle = "The 25–34 segment delivers the strongest ROI across most channels.",
    x        = "Audience Segment (Age)",
    y        = "Average ROI (%)",
    caption  = "Source: Marketing Campaign Performance Dataset (2023)"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title    = element_text(face = "bold", size = 14),
    plot.subtitle = element_text(color = "grey40", size = 11),
    strip.text    = element_text(face = "bold", size = 12),
    panel.grid.major.x = element_blank(),
    panel.grid.minor   = element_blank()
  )

Interpretation: The 25–34 age segment consistently generates the highest ROI across Search, Social, and Email channels, likely reflecting higher purchasing intent and digital engagement. The 55+ segment underperforms in Social but performs competitively in Email, suggesting channel–audience alignment is a key driver of returns.


Figure 7: Spend vs. CTR Dumbbell Chart (Low vs. High Spend)

Figure type: Dumbbell Chart

fig7_dat <- dat %>%
  group_by(channel) %>%
  mutate(spend_quartile = ntile(spend, 4)) %>%
  filter(spend_quartile %in% c(1, 4)) %>%
  group_by(channel, spend_quartile) %>%
  summarise(mean_ctr = mean(ctr, na.rm = TRUE), .groups = "drop") %>%
  mutate(spend_group = if_else(spend_quartile == 1, "Low Spend\n(Q1)", "High Spend\n(Q4)"))

fig7_wide <- fig7_dat %>%
  select(channel, spend_group, mean_ctr) %>%
  pivot_wider(names_from = spend_group, values_from = mean_ctr) %>%
  rename(low = `Low Spend\n(Q1)`, high = `High Spend\n(Q4)`)

ggplot(fig7_wide) +
  geom_segment(aes(x = low, xend = high,
                   y = channel, yend = channel),
               color = "grey70", linewidth = 2) +
  geom_point(aes(x = low, y = channel, color = "Low Spend (Q1)"), size = 5) +
  geom_point(aes(x = high, y = channel, color = "High Spend (Q4)"), size = 5) +
  scale_color_manual(values = c("Low Spend (Q1)" = "#90CAF9",
                                "High Spend (Q4)" = "#1565C0")) +
  scale_x_continuous(labels = scales::percent_format(accuracy = 0.01)) +
  labs(
    title    = "Figure 7: CTR at Low vs. High Spend Levels by Channel",
    subtitle = "Higher spend does not uniformly improve CTR — Display shows near-zero difference.",
    x        = "Average CTR",
    y        = NULL,
    color    = "Spend Level",
    caption  = "Source: Marketing Campaign Performance Dataset (2023)"
  ) +
  theme_minimal(base_size = 13) +
  theme(
    plot.title    = element_text(face = "bold", size = 14),
    plot.subtitle = element_text(color = "grey40", size = 11),
    panel.grid.major.y = element_blank(),
    legend.position    = "top",
    axis.text.y        = element_text(size = 12, face = "bold")
  )

Interpretation: For Search and Email, higher spend campaigns achieve modestly better CTRs, suggesting quality creative investment pays off. Display shows virtually no difference between low- and high-spend campaigns, implying that for Display, budget alone does not drive engagement — placement and targeting matter more.


Figure 8 (Interactive): CTR, Conversion Rate & ROI by Channel Over Time

Figure type: Interactive Multi-Line Plot (plotly)

fig8_dat <- dat %>%
  group_by(month, channel) %>%
  summarise(
    avg_ctr        = mean(ctr, na.rm = TRUE),
    avg_conv_rate  = mean(conversions / pmax(clicks, 1), na.rm = TRUE),
    avg_roi        = mean(roi, na.rm = TRUE),
    .groups        = "drop"
  ) %>%
  mutate(month_date = as.Date(paste0(month, "-01")))

# Build three traces per channel for CTR
plot_ly() %>%

  # --- CTR traces ---
  add_trace(data = filter(fig8_dat, channel == "Search"),
            x = ~month_date, y = ~avg_ctr,
            name = "Search — CTR", type = "scatter", mode = "lines+markers",
            line = list(color = "#2196F3", width = 2),
            marker = list(color = "#2196F3", size = 6),
            yaxis = "y1", legendgroup = "Search",
            hovertemplate = "Search CTR: %{y:.2%}<br>%{x}<extra></extra>") %>%
  add_trace(data = filter(fig8_dat, channel == "Social"),
            x = ~month_date, y = ~avg_ctr,
            name = "Social — CTR", type = "scatter", mode = "lines+markers",
            line = list(color = "#FF5722", width = 2),
            marker = list(color = "#FF5722", size = 6),
            yaxis = "y1", legendgroup = "Social",
            hovertemplate = "Social CTR: %{y:.2%}<br>%{x}<extra></extra>") %>%
  add_trace(data = filter(fig8_dat, channel == "Display"),
            x = ~month_date, y = ~avg_ctr,
            name = "Display — CTR", type = "scatter", mode = "lines+markers",
            line = list(color = "#4CAF50", width = 2),
            marker = list(color = "#4CAF50", size = 6),
            yaxis = "y1", legendgroup = "Display",
            hovertemplate = "Display CTR: %{y:.2%}<br>%{x}<extra></extra>") %>%
  add_trace(data = filter(fig8_dat, channel == "Email"),
            x = ~month_date, y = ~avg_ctr,
            name = "Email — CTR", type = "scatter", mode = "lines+markers",
            line = list(color = "#9C27B0", width = 2),
            marker = list(color = "#9C27B0", size = 6),
            yaxis = "y1", legendgroup = "Email",
            hovertemplate = "Email CTR: %{y:.2%}<br>%{x}<extra></extra>") %>%

  # --- ROI traces (secondary axis) ---
  add_trace(data = filter(fig8_dat, channel == "Search"),
            x = ~month_date, y = ~avg_roi,
            name = "Search — ROI", type = "scatter", mode = "lines",
            line = list(color = "#2196F3", width = 1.5, dash = "dot"),
            yaxis = "y2", legendgroup = "Search", showlegend = FALSE,
            hovertemplate = "Search ROI: %{y:.1f}%<br>%{x}<extra></extra>") %>%
  add_trace(data = filter(fig8_dat, channel == "Social"),
            x = ~month_date, y = ~avg_roi,
            name = "Social — ROI", type = "scatter", mode = "lines",
            line = list(color = "#FF5722", width = 1.5, dash = "dot"),
            yaxis = "y2", legendgroup = "Social", showlegend = FALSE,
            hovertemplate = "Social ROI: %{y:.1f}%<br>%{x}<extra></extra>") %>%
  add_trace(data = filter(fig8_dat, channel == "Display"),
            x = ~month_date, y = ~avg_roi,
            name = "Display — ROI", type = "scatter", mode = "lines",
            line = list(color = "#4CAF50", width = 1.5, dash = "dot"),
            yaxis = "y2", legendgroup = "Display", showlegend = FALSE,
            hovertemplate = "Display ROI: %{y:.1f}%<br>%{x}<extra></extra>") %>%
  add_trace(data = filter(fig8_dat, channel == "Email"),
            x = ~month_date, y = ~avg_roi,
            name = "Email — ROI", type = "scatter", mode = "lines",
            line = list(color = "#9C27B0", width = 1.5, dash = "dot"),
            yaxis = "y2", legendgroup = "Email", showlegend = FALSE,
            hovertemplate = "Email ROI: %{y:.1f}%<br>%{x}<extra></extra>") %>%

  layout(
    title = list(
      text = "<b>Figure 8: Monthly CTR (solid) & ROI (dotted) by Channel — Interactive</b><br><sup>Hover for values. Click legend to toggle channels.</sup>",
      font = list(size = 14)
    ),
    xaxis  = list(title = "", tickformat = "%b %Y", showgrid = FALSE),
    yaxis  = list(title = "Average CTR", tickformat = ".2%",
                  showgrid = TRUE, gridcolor = "#f0f0f0"),
    yaxis2 = list(title = "Average ROI (%)", overlaying = "y", side = "right",
                  showgrid = FALSE, ticksuffix = "%"),
    legend = list(orientation = "h", x = 0, y = -0.18),
    hovermode = "x unified",
    plot_bgcolor  = "#ffffff",
    paper_bgcolor = "#ffffff",
    margin = list(t = 80, r = 80)
  )

Interpretation: The interactive chart reveals that CTR and ROI trends do not always move in lockstep — months with peak CTR do not always produce the highest ROI, pointing to differences in conversion quality and revenue per conversion across periods. Users can toggle individual channels on/off to isolate comparisons.


Summary

Figure Type Key Finding
1 Horizontal Bar Chart Search has highest CTR (4.5%); Display lowest (0.8%)
2 Line Plot Social & Search drive volume; Q4 seasonal spike across all channels
3 Violin + Box Plot Email has widest ROI upside; Display most compressed near break-even
4 Faceted Scatterplot Search & Email have best revenue-to-spend slopes
5 Heatmap Weekdays > weekends; Oct–Nov peak engagement period
6 Faceted Bar Chart 25–34 age segment delivers strongest ROI across most channels
7 Dumbbell Chart Spend level barely moves Display CTR; Search & Email benefit from higher spend
8 Interactive Line Plot (plotly) CTR and ROI trends diverge seasonally, highlighting conversion quality variation

Report prepared by N Nikhil Kumar | Data Visualization Capstone