Copilot usage segments over time

Introduction

When measuring Copilot adoption, a single snapshot (“what share of people used Copilot last week?”) can be misleading, because usage is a habit that builds up (or decays) over many weeks. The vivainsights package ships a function, identify_usage_segments(), that classifies every person-week into an adoption segment (Power User, Habitual User, Novice User, Low User or Non-user) using a rolling window of Copilot actions.

This notebook shows how to:

  1. build a total Copilot-actions metric from the individual Copilot action columns in a Person Query,
  2. classify each person-week with identify_usage_segments(version = "12w"), and
  3. visualise how the mix of segments evolves over time with a stacked-area chart, alongside the trend in average Copilot actions.

We use the built-in pq_data sample so the notebook runs end-to-end with no external files. Before adapting it to an export loaded with vivainsights::import_query("your-person-query.csv"), verify the metric schema, eligibility and measurement coverage described below.

Set-up

knitr::opts_chunk$set(warning = FALSE, message = FALSE)
library(dplyr)
library(tidyr)
library(ggplot2)
library(scales)
library(vivainsights)

For clarity in this demonstration we use the explicit package::function() notation in a few places to show which package each function comes from.

Load data and build a total Copilot-actions metric

identify_usage_segments() expects a single metric column that captures Copilot intensity. A Person Query splits Copilot activity across several Copilot_actions_taken_in_* columns. This example sums the six named metrics below (Teams, Copilot chat (work), Excel, Outlook, PowerPoint and Word) into Total_Copilot_actions_taken; it is not a total across every possible Copilot surface. These names match the built-in sample’s schema.

All six columns must be present and contain numeric, finite, non-negative counts for every included person-week. An observed 0 is valid no activity; a missing column or NA is unknown coverage, not evidence of non-use. The notebook stops on invalid inputs rather than filling them with zeros or silently excluding them from the denominator.

data("pq_data", package = "vivainsights")

app_cols <- c(
  "Copilot_actions_taken_in_Teams",
  "Copilot_actions_taken_in_Copilot_chat__work_",
  "Copilot_actions_taken_in_Excel",
  "Copilot_actions_taken_in_Outlook",
  "Copilot_actions_taken_in_Powerpoint",
  "Copilot_actions_taken_in_Word"
)
app_cols
## [1] "Copilot_actions_taken_in_Teams"
## [2] "Copilot_actions_taken_in_Copilot_chat__work_"
## [3] "Copilot_actions_taken_in_Excel"
## [4] "Copilot_actions_taken_in_Outlook"
## [5] "Copilot_actions_taken_in_Powerpoint"
## [6] "Copilot_actions_taken_in_Word"
missing_cols <- setdiff(app_cols, names(pq_data))
if (length(missing_cols)) {
  stop("Missing required Copilot action columns: ",
       paste(missing_cols, collapse = ", "),
       ". Select these query metrics and verify coverage before classifying use.",
       call. = FALSE)
}

invalid_cols <- app_cols[vapply(pq_data[app_cols], function(x) {
  !is.numeric(x) || anyNA(x) || any(!is.finite(x)) || any(x < 0)
}, logical(1))]
if (length(invalid_cols)) {
  stop("Copilot action counts must be numeric, finite, non-missing and non-negative: ",
       paste(invalid_cols, collapse = ", "),
       ". Check query coverage and correct the input; unknown counts are not zero activity.",
       call. = FALSE)
}

pq <- pq_data %>%
  mutate(Total_Copilot_actions_taken = rowSums(across(all_of(app_cols))))
if (any(!is.finite(pq$Total_Copilot_actions_taken))) {
  stop("Total Copilot actions are not finite. Check the input counts and metric scale.",
       call. = FALSE)
}

# A quick look at the panel structure
pq %>%
  summarise(
    persons = dplyr::n_distinct(PersonId),
    weeks   = dplyr::n_distinct(MetricDate),
    from    = min(MetricDate),
    to      = max(MetricDate)
  )
## # A tibble: 1 × 4
##   persons weeks from       to
##     <int> <int> <date>     <date>
## 1     300    23 2024-04-28 2024-09-29

Classify each person-week into a usage segment

identify_usage_segments() with version = "12w" applies the standard 12-week rolling definition: a person’s segment in a given week depends on their Copilot actions over that week and the preceding weeks. Returning return = "data" appends the classification columns to the input frame.

seg <- vivainsights::identify_usage_segments(
  data    = pq,
  metric  = "Total_Copilot_actions_taken",
  version = "12w",
  return  = "data"
)

seg <- seg %>%
  mutate(UsageSegments_12w = factor(
    UsageSegments_12w,
    levels = c("Power User", "Habitual User", "Novice User",
               "Low User", "Non-user")
  ))

# Overall distribution of person-weeks across segments
seg %>%
  count(UsageSegments_12w) %>%
  mutate(share = scales::percent(n / sum(n), accuracy = 0.1))
## # A tibble: 4 × 3
##   UsageSegments_12w     n share
##   <fct>             <int> <chr>
## 1 Power User            7 0.1%
## 2 Habitual User      4479 64.9%
## 3 Novice User        2411 34.9%
## 4 Non-user              3 0.0%

Note on the rolling window. Because the 12-week version looks back up to 12 weeks, the earliest weeks in any export are based on a shorter window and are therefore less stable. When you have a long enough history, it is common to drop the first ~12 weeks before interpreting the trend. With the short sample here we keep all weeks but flag the caveat.

Segment mix over time (stacked area)

The clearest way to show adoption momentum is the share of the population in each segment, week by week. A stacked-area chart makes the shift from lighter to heavier usage (or vice versa) easy to read.

seg_share <- seg %>%
  count(MetricDate, UsageSegments_12w, name = "n") %>%
  group_by(MetricDate) %>%
  mutate(share = n / sum(n)) %>%
  ungroup()

seg_palette <- c(
  "Power User"    = "#1b4965",
  "Habitual User" = "#5fa8d3",
  "Novice User"   = "#cae9ff",
  "Low User"      = "#f4a259",
  "Non-user"      = "#bc4b51"
)

ggplot(seg_share, aes(x = MetricDate, y = share, fill = UsageSegments_12w)) +
  geom_area(alpha = 0.9) +
  scale_y_continuous(labels = scales::percent) +
  scale_fill_manual(values = seg_palette, name = "Usage segment") +
  labs(
    title    = "Copilot usage-segment mix over time",
    subtitle = "Share of person-weeks in each 12-week rolling segment",
    x = NULL, y = "Share of population"
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "top")

Average Copilot actions over time

Alongside the segment mix, it is useful to plot the trend in mean Copilot actions per person-week. Rising average actions with a growing Power/Habitual share is the signature of healthy adoption.

actions_trend <- seg %>%
  group_by(MetricDate) %>%
  summarise(mean_actions = mean(Total_Copilot_actions_taken),
            .groups = "drop")

ggplot(actions_trend, aes(x = MetricDate, y = mean_actions)) +
  geom_line(linewidth = 1.1, colour = "#1b4965") +
  geom_point(size = 2, colour = "#1b4965") +
  labs(
    title    = "Average Copilot actions per person-week",
    subtitle = "Mean of Total_Copilot_actions_taken across the population",
    x = NULL, y = "Mean Copilot actions"
  ) +
  theme_minimal(base_size = 12)

Wrapping up

With three short steps, namely summing the Copilot action columns, calling identify_usage_segments(version = "12w"), and aggregating by MetricDate, we turned a raw Person Query into a longitudinal view of Copilot adoption. The same workflow can be adapted to a real export only after validating its source contract. Map the six metrics explicitly if names differ; do not omit a metric or insert zeros just to pass the checks. Numeric completeness alone does not establish eligibility or telemetry coverage. Confirm which people and weeks were measured, including missing person-weeks and any source-provided zeros that may represent unavailable data. If coverage is unresolved, stop the analysis or design and disclose a separate unknown-coverage policy before reporting adoption. The notebook does not infer that policy or perform a real-data privacy review. Treat the earliest weeks with caution because of the 12-week warm-up window.