knitr::opts_chunk$set(warning = FALSE, message = FALSE)
library(tidyverse)
library(vivainsights)
library(randomForest) # random forests, matching the repo's existing RF example
Many meeting-culture conversations start with “we have too many meetings”. A more useful question is often “are we meeting well?”, that is, are the people in a meeting actually engaged, or are they quietly working on something else while it runs?
Viva Insights captures a few behavioural signals for every meeting that let us approximate engagement, including the number of attendees multitasking and the number of chats and emails sent during the meeting. In this example we treat in-meeting messaging as an unvalidated proxy for disengagement and ask a modelling question: which characteristics of a meeting best predict how much backchannel messaging it generates?
We will:
Everything below runs on a small simulated Meeting
Query created inside this document, so it is fully reproducible with no
data of your own. The package does ship a sample Meeting Query
(vivainsights::mt_data), but it is small and mostly
single-person focus blocks, so it lacks the multi-person meetings and
in-meeting messaging this model needs. We use a seeded simulation
instead, built with the similar column names to a Meeting Query.
Matching names does not validate proxy meaning, coverage or the analysis
contract for your own export.
We begin by simulating a Meeting Query. The data-generating process
makes duration the dominant driver of messaging, with a
sub-linear (exponent < 1) relationship, so total messaging rises with
length while per-minute messaging gently declines, which creates a
plausible pattern for demonstration. The column names resemble a Meeting
Query, but any tenant adaptation requires separate schema, privacy and
proxy-validity review. (The shipped vivainsights::mt_data
is handy for inspecting the expected schema, but is too small and
focus-block-heavy to fit this model.)
simulate_meeting_query <- function(n = 2000, seed = 42) {
set.seed(seed)
duration_min <- sample(c(15, 30, 45, 60, 90, 120, 180), n, replace = TRUE,
prob = c(.14, .28, .20, .18, .10, .06, .04))
n_attendees <- 2 + rpois(n, 4) # at least 2
intended <- n_attendees + rpois(n, 1)
recurring <- rbinom(n, 1, 0.5)
accept <- rbinom(n, intended, 0.70) # RSVP responses out of invitees
remaining <- intended - accept
no_response <- rbinom(n, remaining, 0.60)
decline <- remaining - no_response
redundant <- rbinom(n, n_attendees, 0.10)
no_response_rate <- no_response / pmax(intended, 1)
# Per-attendee messaging rate: duration dominates, sub-linearly (^0.85)
lam <- 0.03 * duration_min^0.85 *
(1 + 0.8 * no_response_rate) *
(1 + 0.05 * log(n_attendees))
chats <- rpois(n, lam * 0.5 * n_attendees)
emails <- rpois(n, lam * 0.5 * n_attendees)
tibble::tibble(
Number_of_attendees = n_attendees,
Number_of_attendees_multitasking = rbinom(n, n_attendees, 0.3),
Number_of_chats_sent_during_the_meeting = chats,
Number_of_emails_sent_during_the_meeting = emails,
Number_of_redundant_attendees = redundant,
All_Day_Meeting = "FALSE",
Cancelled = "FALSE",
Recurring = ifelse(recurring == 1, "TRUE", "FALSE"),
Accept_count = accept,
No_response_count = no_response,
Decline_count = decline,
Intended_participant_count = intended,
Duration = duration_min / 60 # Duration is in HOURS
)
}
mt_raw <- simulate_meeting_query()
Now a light quality filter to keep genuine meetings only, dropping solo calendar blocks, cancelled items, and all-day entries. (On your own data you would apply a fuller cleaning step; the point here is simply to model real meetings.)
# Coerce the TRUE/FALSE flags robustly (they may load as strings)
as_flag <- function(x) as.logical(as.character(x))
mt <- mt_raw %>%
mutate(
Cancelled = as_flag(Cancelled),
All_Day_Meeting = as_flag(All_Day_Meeting),
Recurring = as_flag(Recurring)
) %>%
filter(
Number_of_attendees >= 2, # drop solo holds / focus blocks
!Cancelled,
!All_Day_Meeting,
Duration > 0
)
nrow(mt)
## [1] 2000
Next we engineer the modelling frame. Note that Duration
in a Meeting Query is expressed in hours, so we convert
it to minutes for readability. We normalise the messaging counts
per attendee so that large meetings do not
automatically look busier, and we derive a few RSVP “commitment” signals
(what share of invitees accepted, did not respond, or declined) that
often turn out to matter.
model_df <- mt %>%
transmute(
# --- outcomes: in-meeting messaging per attendee ---
chats_per_att = Number_of_chats_sent_during_the_meeting / Number_of_attendees,
emails_per_att = Number_of_emails_sent_during_the_meeting / Number_of_attendees,
# --- candidate design predictors ---
duration_min = Duration * 60,
n_attendees = Number_of_attendees,
intended_participants = Intended_participant_count,
recurring = factor(Recurring),
accept_rate = Accept_count / pmax(Intended_participant_count, 1),
no_response_rate = No_response_count / pmax(Intended_participant_count, 1),
decline_rate = Decline_count / pmax(Intended_participant_count, 1),
redundant_share = Number_of_redundant_attendees / pmax(Number_of_attendees, 1)
) %>%
drop_na()
head(model_df)
## # A tibble: 6 × 10
## chats_per_att emails_per_att duration_min n_attendees intended_participants
## <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 0.818 1.55 120 11 12
## 2 0.667 0.333 120 6 9
## 3 1.14 0.143 45 7 8
## 4 1 0.375 90 8 10
## 5 0.75 0.375 60 8 8
## 6 1 1.14 60 7 8
## # ℹ 5 more variables: recurring <fct>, accept_rate <dbl>,
## # no_response_rate <dbl>, decline_rate <dbl>, redundant_share <dbl>
We fit a random forest with the randomForest package
(the same package used in this repo’s top-performers example), setting
importance = TRUE so we can read off permutation-based
importance, the %IncMSE, i.e. how much the prediction error
rises when each feature is shuffled. Random forests capture non-linear
effects and interactions without us specifying them.
We fit one model per outcome (chats per attendee and emails per attendee), which mirrors the idea that chat and email backchannels can behave a little differently.
set.seed(123)
predictors <- c("duration_min", "n_attendees", "intended_participants",
"recurring", "accept_rate", "no_response_rate",
"decline_rate", "redundant_share")
rf_chats <- randomForest::randomForest(
x = model_df[predictors],
y = model_df$chats_per_att,
ntree = 500,
importance = TRUE
)
rf_emails <- randomForest::randomForest(
x = model_df[predictors],
y = model_df$emails_per_att,
ntree = 500,
importance = TRUE
)
The out-of-bag (OOB) R-squared tells us how much of the variation in messaging the features explain. For human behaviour, modest values are entirely expected, and we report them as an association, not a causal effect.
tibble(
outcome = c("chats per attendee", "emails per attendee"),
oob_rsq = c(tail(rf_chats$rsq, 1), tail(rf_emails$rsq, 1))
)
## # A tibble: 2 × 2
## outcome oob_rsq
## <chr> <dbl>
## 1 chats per attendee 0.463
## 2 emails per attendee 0.450
Now the part we care about: the ranked importance of each design
feature. We plot the permutation importance (%IncMSE) for
the chats model (the more differentiating of the two signals).
imp_df <- tibble(
feature = rownames(randomForest::importance(rf_chats)),
importance = randomForest::importance(rf_chats)[, "%IncMSE"]
) %>%
arrange(importance)
ggplot(imp_df, aes(x = importance, y = reorder(feature, importance))) +
geom_col(fill = "#1B3A6B") +
labs(
title = "What drives in-meeting chat, per attendee",
subtitle = "Permutation importance (%IncMSE) from a random forest",
x = "Permutation importance", y = NULL
) +
theme_minimal(base_size = 12)
Typically meeting duration ranks at or near the top, alongside meeting size and the RSVP commitment signals. That already points at a practical lever, because duration is one of the few meeting properties you can change with a single calendar setting.
Whenever “longer meetings have more messaging” comes up, a fair objection follows: isn’t that trivial, since a longer meeting simply contains more minutes in which to send a message? It is worth testing directly rather than assuming, because the answer changes the recommendation.
We group meetings into duration bands and compute messaging two ways: the total per attendee, and the same figure per minute. If the per-minute rate were flat, the total would be pure exposure (just more minutes). If it varies, something more interesting is going on.
dose <- model_df %>%
mutate(
msgs_per_att = chats_per_att + emails_per_att,
msgs_per_att_permin = msgs_per_att / duration_min,
band = cut(
duration_min,
breaks = c(0, 15, 30, 60, 120, Inf),
labels = c("<=15m", "16-30m", "31-60m", "61-120m", ">120m"),
right = TRUE
)
) %>%
group_by(band) %>%
summarise(
n_meetings = n(),
msgs_per_att = mean(msgs_per_att, na.rm = TRUE),
msgs_per_att_permin = mean(msgs_per_att_permin, na.rm = TRUE),
.groups = "drop"
)
dose
## # A tibble: 5 × 4
## band n_meetings msgs_per_att msgs_per_att_permin
## <fct> <int> <dbl> <dbl>
## 1 <=15m 280 0.354 0.0236
## 2 16-30m 593 0.647 0.0216
## 3 31-60m 726 1.05 0.0203
## 4 61-120m 332 1.91 0.0186
## 5 >120m 69 3.06 0.0170
These are equally weighted means of meeting-level counts and meeting-level rates, not a ratio of group means. The simulator deliberately plants increasing total messaging and a gently declining per-minute rate. Neither this pattern nor in-meeting messaging proves disengagement: messages can also support the meeting.
A simple linear model gives us the marginal effect of length, holding meeting size constant, as an interpretable companion to the forest.
lm_dur <- lm(
I(chats_per_att + emails_per_att) ~ duration_min + n_attendees,
data = model_df
)
# Extra messages per attendee for each additional 30 minutes
coef(lm_dur)[["duration_min"]] * 30
## [1] 0.5024913
This is an educational association model with a planted duration relationship, not evidence that shortening meetings improves engagement. The model does not observe attention, message purpose or meeting value. Real-data use requires schema, coverage, privacy and proxy-validity review; column-name similarity alone does not make a tenant export interchangeable with this simulation.
A few transferable practices for this kind of analysis:
Finally, there is a natural link to AI adoption. Some in-meeting messaging is simply people trying to stay productive while stuck in a session that does not need all of them. Capabilities such as Microsoft 365 Copilot meeting recaps let people skip or leave lower-value meetings and catch up afterwards, which is complementary to shortening meetings: one removes the room for disengagement, the other removes the reason for it. A useful extension is to test whether higher Copilot adoption is associated with a lighter backchannel in long meetings.