Baseline Profiling with Control Charts and the Explore-in-R-Enforce-in-Python Discipline
The explore-in-R-enforce-in-Python discipline has appeared in two prior Data Science lessons. This lesson applies it to the security domain: the same two-language workflow that served on-chain event analysis and operational SLI monitoring now serves behavioral anomaly detection for multi-agent service accounts.
Training-window baseline profiling. Load the audit log, summarise to 5-minute windows, compute rolling statistics, render control charts, select N-sigma with visual evidence, export baseline parameters as JSON.
Production anomaly gate. Read the JSON baseline parameters at startup, score each completed observation window against the gate, pass the score to the alert-to-isolate pipeline.
Two languages. Two problem shapes. One discipline. The boundary between them is the exported JSON — the compact between the R analyst and the Python runtime. When baselines refresh, the R session runs, produces a new JSON, and the Python gate loads it at the next startup without any change to the gate code.
The Shewhart control chart is the analytic instrument at the center of today's R work. Wickham and Grolemund's grammar of graphics separates the chart into three composable layers: data, aesthetic mapping, and geometric representation (Wickham, Grolemund, Part I Explore, p30). A control chart adds a center line (mean), UCL (mean + N × sigma), and LCL (mean − N × sigma). Points outside the control limits are breach events; color and shape encode breach state using the aesthetic mapping discussed at pp66–67 — color-redundant shape encoding so the chart is readable in greyscale.
The data structure is a tibble: one row per 5-minute observation window per service account, four behavioral dimension columns (call_volume, fan_out, cross_ns_calls, active_hour_flag), and two index columns (sa_name, window_start as POSIXct).
across() call applies both mean and sd to all four behavioral columns in one pass, producing output columns named call_volume_mean, call_volume_sigma, etc. The result is a one-row tibble — the baseline contract for one service account.
library(tidyverse)
library(slider)
compute_baseline <- function(df, sa) {
df |>
filter(sa_name == sa) |>
arrange(window_start) |>
summarise(
across(
c(call_volume, fan_out, cross_ns_calls, active_hour_flag),
list(mean = mean, sigma = sd),
.names = "{.col}_{.fn}"
)
)
}
Before committing to a threshold, check whether each dimension is stable under known-good conditions. The coefficient of variation (cv = sigma / mean) is the quick diagnostic: cv below 0.3 means the dimension is tight and worth gating at N=3; cv above 0.8 means the dimension is too noisy for reliable gating at that threshold.
slide_dbl function applies mean and sd over a 12-period sliding window (one hour at 5-minute intervals). The output cv column shows how the coefficient of variation evolves over time — rising cv indicates a dimension whose baseline is drifting.
rolling_cv <- function(df, sa, dimension) {
df |>
filter(sa_name == sa) |>
arrange(window_start) |>
mutate(
roll_mean = slide_dbl(.data[[dimension]], mean, .before = 11),
roll_sd = slide_dbl(.data[[dimension]], sd, .before = 11),
cv = roll_sd / roll_mean
) |>
select(window_start, roll_mean, roll_sd, cv)
}
The control chart plots each 5-minute observation; UCL and LCL are mean ± 3 × sigma; breached points are colored orange-red and shaped as triangles for redundant encoding.
plot_control_chart <- function(obs_df, baseline_row, dimension, sa) {
mu <- baseline_row[[paste0(dimension, "_mean")]]
sigma <- baseline_row[[paste0(dimension, "_sigma")]]
ucl <- mu + 3 * sigma
lcl <- max(0, mu - 3 * sigma)
obs_df |>
filter(sa_name == sa) |>
arrange(window_start) |>
mutate(breached = .data[[dimension]] > ucl | .data[[dimension]] < lcl) |>
ggplot(aes(x = window_start, y = .data[[dimension]])) +
geom_line(color = "#4a6dd0", linewidth = 0.6) +
geom_point(aes(color = breached, shape = breached), size = 2) +
geom_hline(yintercept = mu, color = "#2E8B57", linewidth = 0.8) +
geom_hline(yintercept = ucl, color = "#5ad6e0", linetype = "dashed") +
geom_hline(yintercept = lcl, color = "#5ad6e0", linetype = "dashed") +
scale_color_manual(values = c("FALSE" = "#a8b0c0", "TRUE" = "#d97757")) +
scale_shape_manual(values = c("FALSE" = 16, "TRUE" = 17)) +
labs(title = paste(sa, "—", dimension), x = NULL, y = dimension,
color = "Breached", shape = "Breached") +
theme_minimal(base_size = 13)
}
auto_unbox = TRUE prevents single-value scalars from being JSON-wrapped in arrays, keeping the Python parse path simple.
library(jsonlite)
export_baselines <- function(baseline_list, path) {
baseline_list |>
toJSON(pretty = TRUE, auto_unbox = TRUE) |>
writeLines(path)
}
stream function reads the JSON lines file lazily. floor_date from lubridate snaps each timestamp to its 5-minute interval. The result is one row per API call, which the next function summarises by service account and window.
library(tidyverse)
library(jsonlite)
library(lubridate)
load_audit_log <- function(path) {
stream(path, handler = function(x) {
tibble(
window_start = floor_date(ymd_hms(x$requestReceivedTimestamp), "5 minutes"),
sa_name = x$user$username,
verb = x$verb,
resource = x$objectRef$resource,
namespace = x$objectRef$namespace
)
}) |>
bind_rows()
}
summarise_windows <- function(calls_df) {
calls_df |>
group_by(sa_name, window_start) |>
summarise(
call_volume = n(),
fan_out = n_distinct(paste(resource, namespace)),
cross_ns_calls = sum(namespace != first(namespace), na.rm = TRUE),
active_hour_flag = as.integer(hour(first(window_start)) %in% 6:22),
.groups = "drop"
)
}
load_baselines reads the JSON exported from the R session and builds the nested Baseline dict. The outer key is service account name; the inner key is dimension name. anomaly_score counts breached dimensions — identical to the Ops lesson's gate function, now reading from R-produced parameters.
import json
from pathlib import Path
from dataclasses import dataclass
@dataclass
class Baseline:
mean: float
sigma: float
n_sigma: float = 3.0
def breached(self, value: float) -> bool:
return abs(value - self.mean) > self.n_sigma * self.sigma
def load_baselines(path: str) -> dict[str, dict[str, Baseline]]:
raw = json.loads(Path(path).read_text())
return {
sa: {
dim: Baseline(
mean=params[f"{dim}_mean"],
sigma=params[f"{dim}_sigma"],
)
for dim in ("call_volume", "fan_out", "cross_ns_calls", "active_hour_flag")
}
for sa, params in raw.items()
}
def anomaly_score(obs: dict, baselines: dict[str, Baseline]) -> int:
return sum(1 for key, bl in baselines.items() if bl.breached(obs[key]))
The Ops lesson defined the baseline contract and alert-to-isolate pipeline at the architectural level. This lesson implements the statistical machinery that fills in the baseline contract's parameters and validates them visually before production deployment.
The workflow is sequential: first the R session, then the Python gate. The R session runs once per baseline refresh. The Python gate runs continuously. The disciplines have different error modes: the R session can fail analytically — wrong sigma choice — but the failure is caught at the chart stage before deployment; the Python gate can fail operationally — stale baselines — but the R session is not in the operational path.
The June 11 on-chain event analysis lesson coined the explore-in-R-enforce-in-Python pattern against blockchain data. The dplyr and ggplot2 vocabulary introduced there reappears here with a security-domain data structure rather than a blockchain event stream.
The June 18 operational telemetry analysis lesson applied the same pattern to SLI time-series data. The slide_dbl rolling-window pattern from that lesson is the direct precedent for today's rolling_cv function. Across three lessons, the discipline is consistent: R for statistical exploration, Python for production enforcement, JSON as the boundary.
Two languages. Two problem shapes. R handles the shape of exploratory statistical analysis: load the training data, compute rolling statistics, visualize the distribution, choose the threshold with visual evidence, export the parameters. Python handles the shape of production enforcement: read the parameters at startup, score each observation window in the live stream, hand the score to the action pipeline.
The explore-in-R-enforce-in-Python discipline is not about language preference. It is about matching tools to the shape of the problem. The R session produces a decision; the Python gate executes that decision at scale, at speed, without second-guessing it. Examine this well.