Skip to content

Commit 87aadb5

Browse files
mattyoreillyclaude
andcommitted
Autonomous runs and a protected test split
- autonomous = TRUE: agent states its plan and proceeds, never asks; system prompt drops the approval gate - test_prop holds out rows the agent never sees; Atlas evaluates all final models on them (auto RMSE/accuracy) into test_leaderboard, persisted and shown by print() - task prompt builder drops NULL sections instead of blank gaps Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent be30e97 commit 87aadb5

9 files changed

Lines changed: 308 additions & 32 deletions

File tree

R/atlas.R

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,22 @@
5353
#' `options(atlas.dir = "~/atlas-runs")` in your `.Rprofile` to send every
5454
#' run somewhere of your choosing, or pass `dir` explicitly.
5555
#' @param verbose Stream the agent's narration to the console.
56+
#' @param autonomous Run with no human in the loop: the agent states its plan
57+
#' and proceeds instead of waiting for approval, and never asks questions.
58+
#' Combine with a generous `n_models`/`patience` and `test_prop` for
59+
#' unattended experimentation runs — e.g.
60+
#' `atlas(d, "y", autonomous = TRUE, n_models = 10, patience = 8,
61+
#' test_prop = 0.2)` — where the agent iterates keep/discard experiments
62+
#' and the survivors are judged on the held-out test set at the end.
63+
#' @param test_prop Proportion of rows (0 to <1) to hold out as a final test
64+
#' set the agent never sees. After the run, Atlas itself evaluates every
65+
#' final model on it (RMSE for continuous outcomes, accuracy otherwise) —
66+
#' a ranking the agent can't overfit. Reported as `test_leaderboard` in
67+
#' the results and saved to `test_leaderboard.csv` in the run directory.
68+
#' `0` (default) disables the split.
5669
#' @return An object of class `atlas`: list with `models` (named list of
5770
#' fitted models), `leaderboard` (data.frame of validation metrics),
71+
#' `test_leaderboard` (held-out test metrics, when `test_prop > 0`),
5872
#' `report` (markdown, how each model was built), `code` (every code chunk
5973
#' the agent ran), `dir`, and `session` (the live [AtlasSession], for
6074
#' follow-ups via `$tell()`).
@@ -78,11 +92,13 @@ atlas <- function(data, outcome, n_models = 3, goal = NULL,
7892
constraints = NULL, chat = NULL, dir = NULL,
7993
verbose = TRUE, max_fix_rounds = 2,
8094
patience = 3, min_improve = 0.05, refine = TRUE,
81-
validate = TRUE, exclude = NULL) {
95+
validate = TRUE, exclude = NULL,
96+
autonomous = FALSE, test_prop = 0) {
8297
session <- AtlasSession$new(data, outcome, n_models = n_models, goal = goal,
8398
constraints = constraints, chat = chat, dir = dir,
8499
patience = patience, min_improve = min_improve,
85-
exclude = exclude)
100+
exclude = exclude, autonomous = autonomous,
101+
test_prop = test_prop)
86102
session$build(verbose = verbose, max_fix_rounds = max_fix_rounds,
87103
refine = refine, validate = validate)
88104
session$results()
@@ -94,9 +110,14 @@ print.atlas <- function(x, ...) {
94110
cat("run directory:", x$dir, "\n")
95111
if (is.data.frame(x$leaderboard) && nrow(x$leaderboard) > 0) {
96112
cat("\n")
97-
cli::cli_rule(left = "leaderboard")
113+
cli::cli_rule(left = "leaderboard (agent's validation)")
98114
print_clean(x$leaderboard)
99115
}
116+
if (is.data.frame(x$test_leaderboard) && nrow(x$test_leaderboard) > 0) {
117+
cat("\n")
118+
cli::cli_rule(left = "held-out test set")
119+
print_clean(x$test_leaderboard)
120+
}
100121
cst <- x$constraints
101122
if (is.data.frame(cst) && nrow(cst) > 0) {
102123
bad <- cst[!is.na(cst$passed) & !cst$passed, , drop = FALSE]

R/session.R

Lines changed: 122 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ AtlasSession <- R6::R6Class("AtlasSession",
3838
dir = NULL,
3939
#' @field code Character vector of every code chunk executed so far.
4040
code = character(),
41+
#' @field test_data Held-out test rows (when `test_prop > 0`); never
42+
#' placed in the agent's environment.
43+
test_data = NULL,
4144

4245
#' @description Create a session.
4346
#' @param data A data.frame.
@@ -79,36 +82,54 @@ AtlasSession <- R6::R6Class("AtlasSession",
7982
#' return `NULL` when there is nothing to say. Used by [atlas_app()]'s
8083
#' "Send now" box; front-ends typically read the message from a file or
8184
#' queue.
85+
#' @param autonomous Run without a human: the agent states its plan and
86+
#' proceeds instead of asking for approval, and never calls `ask_user`.
87+
#' @param test_prop Proportion of rows (0 to <1) to hold out as a final
88+
#' test set **the agent never sees**. Final models are evaluated on it
89+
#' by Atlas itself after the run (`$results()$test_leaderboard`), so
90+
#' the comparison can't be gamed by overfitting the agent's own
91+
#' validation scheme. `0` (default) disables the split.
8292
initialize = function(data, outcome, n_models = 3, goal = NULL,
8393
constraints = NULL, chat = NULL, dir = NULL,
8494
on_ask = NULL, display = c("console", "markdown"),
8595
patience = 3, min_improve = 0.05, exclude = NULL,
86-
interject = NULL) {
96+
interject = NULL, autonomous = FALSE,
97+
test_prop = 0) {
8798
private$on_ask <- on_ask
8899
private$interject <- interject
89100
private$display <- match.arg(display)
90101
stopifnot(is.data.frame(data), is.character(outcome), length(outcome) == 1,
91102
is.numeric(n_models), length(n_models) == 1, n_models >= 1,
92103
is.numeric(patience), patience >= 1,
93-
is.numeric(min_improve), min_improve >= 0, min_improve <= 1)
104+
is.numeric(min_improve), min_improve >= 0, min_improve <= 1,
105+
is.numeric(test_prop), test_prop >= 0, test_prop < 1)
94106
if (!outcome %in% names(data)) {
95107
stop("outcome '", outcome, "' is not a column of `data`", call. = FALSE)
96108
}
97109
if (outcome %in% exclude) {
98110
stop("the outcome cannot be excluded", call. = FALSE)
99111
}
100112
data <- data[setdiff(names(data), exclude)]
113+
if (test_prop > 0) {
114+
idx <- seeded_sample(nrow(data), max(1, round(nrow(data) * test_prop)))
115+
self$test_data <- data[idx, , drop = FALSE]
116+
data <- data[-idx, , drop = FALSE]
117+
}
101118
leakage <- tryCatch(atlas_leakage_screen(data, outcome),
102119
error = function(e) NULL)
103120
private$meta <- list(outcome = outcome, n_models = n_models, goal = goal,
104121
constraints = normalize_constraints(constraints),
105122
patience = patience, min_improve = min_improve,
106-
exclude = exclude, leakage = leakage)
123+
exclude = exclude, leakage = leakage,
124+
autonomous = autonomous, test_prop = test_prop)
107125
self$dir <- path.expand(
108126
dir %||% file.path(getOption("atlas.dir", ".atlas"),
109127
format(Sys.time(), "%Y%m%d-%H%M%S")))
110128
dir.create(self$dir, recursive = TRUE, showWarnings = FALSE)
111129
saveRDS(data, file.path(self$dir, "data.rds"))
130+
if (!is.null(self$test_data)) {
131+
saveRDS(self$test_data, file.path(self$dir, "test.rds"))
132+
}
112133
saveRDS(private$meta, file.path(self$dir, "meta.rds"))
113134

114135
self$env <- new.env(parent = globalenv())
@@ -117,7 +138,7 @@ AtlasSession <- R6::R6Class("AtlasSession",
117138
sys_prompt <- atlas_system_prompt(
118139
n_models, length(private$meta$constraints) > 0,
119140
patience = patience, min_improve = min_improve,
120-
has_modelblueprint = mb_available())
141+
has_modelblueprint = mb_available(), autonomous = autonomous)
121142
if (identical(private$display, "markdown")) {
122143
sys_prompt <- paste0(
123144
sys_prompt, "\n\nYour narration and code output are rendered as",
@@ -221,9 +242,17 @@ AtlasSession <- R6::R6Class("AtlasSession",
221242
warning("agent has not produced `atlas_models` yet; ",
222243
"run $build() or inspect $chat", call. = FALSE)
223244
}
245+
test_lb <- NULL
246+
if (!is.null(self$test_data) && !is.null(self$env$atlas_models)) {
247+
test_lb <- evaluate_on_test(self$env$atlas_models, self$test_data,
248+
private$meta$outcome)
249+
utils::write.csv(test_lb, file.path(self$dir, "test_leaderboard.csv"),
250+
row.names = FALSE)
251+
}
224252
structure(
225253
list(models = self$env$atlas_models,
226254
leaderboard = self$env$atlas_leaderboard,
255+
test_leaderboard = test_lb,
227256
constraints = self$check(),
228257
report = private$last_report, code = self$code,
229258
dir = self$dir, session = self),
@@ -460,7 +489,8 @@ atlas_resume <- function(dir, chat = NULL, ...) {
460489
goal = meta$goal, constraints = meta$constraints,
461490
chat = chat, dir = dir,
462491
patience = meta$patience %||% 3,
463-
min_improve = meta$min_improve %||% 0.05, ...)
492+
min_improve = meta$min_improve %||% 0.05,
493+
autonomous = meta$autonomous %||% FALSE, ...)
464494
code_path <- file.path(dir, "code.rds")
465495
if (file.exists(code_path)) {
466496
s$code <- readRDS(code_path)
@@ -470,9 +500,59 @@ atlas_resume <- function(dir, chat = NULL, ...) {
470500
}
471501
turns_path <- file.path(dir, "turns.rds")
472502
if (file.exists(turns_path)) s$chat$set_turns(readRDS(turns_path))
503+
test_path <- file.path(dir, "test.rds")
504+
if (file.exists(test_path)) s$test_data <- readRDS(test_path)
473505
s
474506
}
475507

508+
# Deterministic sample that leaves the caller's RNG stream untouched.
509+
seeded_sample <- function(n, size, seed = 1) {
510+
old <- if (exists(".Random.seed", globalenv(), inherits = FALSE)) {
511+
get(".Random.seed", globalenv())
512+
}
513+
on.exit(if (!is.null(old)) assign(".Random.seed", old, globalenv()),
514+
add = TRUE)
515+
set.seed(seed)
516+
sample.int(n, size)
517+
}
518+
519+
# Pick a sensible test metric from the outcome: RMSE for continuous
520+
# outcomes, accuracy otherwise (thresholding numeric predictions at 0.5
521+
# for binary targets).
522+
auto_metric <- function(y) {
523+
if (is.numeric(y) && length(unique(y)) > 5) {
524+
list(name = "rmse", higher_better = FALSE,
525+
fn = function(actual, predicted) {
526+
sqrt(mean((actual - as.numeric(predicted))^2))
527+
})
528+
} else {
529+
list(name = "accuracy", higher_better = TRUE,
530+
fn = function(actual, predicted) {
531+
if (is.numeric(predicted) && is.numeric(actual) &&
532+
all(actual %in% c(0, 1))) {
533+
predicted <- as.numeric(predicted >= 0.5)
534+
}
535+
mean(as.character(actual) == as.character(predicted))
536+
})
537+
}
538+
}
539+
540+
# Atlas-side final evaluation: the agent never touches the test rows, so
541+
# this ranking can't be gamed. Models that can't predict get NA, not an
542+
# error - one broken candidate shouldn't sink the run.
543+
evaluate_on_test <- function(models, test, outcome) {
544+
m <- auto_metric(test[[outcome]])
545+
rows <- lapply(names(models), function(nm) {
546+
value <- tryCatch(
547+
m$fn(test[[outcome]], stats::predict(models[[nm]], newdata = test)),
548+
error = function(e) NA_real_)
549+
data.frame(model = nm, metric = m$name, value = value)
550+
})
551+
out <- do.call(rbind, rows)
552+
out[order(out$value, decreasing = m$higher_better, na.last = TRUE), ,
553+
drop = FALSE]
554+
}
555+
476556
# modelblueprint is optional: only mention it to the agent (and only allow
477557
# its file writes) when it is actually installed
478558
mb_available <- function() {
@@ -481,7 +561,8 @@ mb_available <- function() {
481561

482562
atlas_system_prompt <- function(n_models, has_constraints = FALSE,
483563
patience = 3, min_improve = 0.05,
484-
has_modelblueprint = TRUE) {
564+
has_modelblueprint = TRUE,
565+
autonomous = FALSE) {
485566
paste(
486567
"You are Atlas, an expert R statistician and ML engineer. You build models",
487568
"by writing R code and running it with the run_r_code tool. You can ask",
@@ -514,17 +595,28 @@ atlas_system_prompt <- function(n_models, has_constraints = FALSE,
514595
" CV, fixed seed). Also consider which predictors should have a",
515596
" monotone effect on the outcome as a matter of domain sense (e.g. a",
516597
" house's price should not fall as floor area grows); include any such",
517-
" suggestions, with direction and a one-line why, in the plan. Present",
518-
" the plan with ask_user, explicitly inviting approval, changes, or",
519-
" extra instructions. Incorporate whatever the user says - if they ask",
520-
" for substantial changes, restate the revised plan in one short",
521-
" paragraph before proceeding - and call add_monotone_constraint for",
522-
" each monotone suggestion the user approves. Never start fitting",
523-
" until the user has responded.",
524-
"3. The user may also steer you mid-build (through ask_user answers or",
525-
" messages in tool results): treat instructions like 'focus on",
526-
" improvements' or 'change the feature engineering' as immediate",
527-
" course corrections, acknowledge them, and adjust the plan.",
598+
if (autonomous) paste0(
599+
" suggestions, with direction and a one-line why, in the plan. You",
600+
"\n are running autonomously: state the plan, apply monotone",
601+
"\n constraints that are clearly right on domain grounds via",
602+
"\n add_monotone_constraint, and proceed without waiting.")
603+
else paste0(
604+
" suggestions, with direction and a one-line why, in the plan. Present",
605+
"\n the plan with ask_user, explicitly inviting approval, changes, or",
606+
"\n extra instructions. Incorporate whatever the user says - if they ask",
607+
"\n for substantial changes, restate the revised plan in one short",
608+
"\n paragraph before proceeding - and call add_monotone_constraint for",
609+
"\n each monotone suggestion the user approves. Never start fitting",
610+
"\n until the user has responded."),
611+
if (autonomous) paste0(
612+
"3. No user is available at any point in this run. Never call",
613+
"\n ask_user; make reasonable decisions yourself and record them in",
614+
"\n your narration.")
615+
else paste0(
616+
"3. The user may also steer you mid-build (through ask_user answers or",
617+
"\n messages in tool results): treat instructions like 'focus on",
618+
"\n improvements' or 'change the feature engineering' as immediate",
619+
"\n course corrections, acknowledge them, and adjust the plan."),
528620
"4. Fit and evaluate each candidate on held-out data. After each one,",
529621
" narrate one line: model name, metric, value.",
530622
"5. Refit each candidate on all rows for the final versions.",
@@ -547,7 +639,10 @@ atlas_system_prompt <- function(n_models, has_constraints = FALSE,
547639
"- Prefer base R; check optional packages with requireNamespace() and fall",
548640
" back gracefully if missing.",
549641
"- Keep each code chunk small; inspect output before continuing.",
550-
"- Use ask_user when a decision genuinely needs the user; otherwise proceed.",
642+
if (autonomous)
643+
"- Never call ask_user: no user is available for this run."
644+
else
645+
"- Use ask_user when a decision genuinely needs the user; otherwise proceed.",
551646
"- Constraints can be added mid-session with add_monotone_constraint.",
552647
" Machine checks require `predict(model, newdata)` to work on a",
553648
" data.frame like `data`; make sure every model in `atlas_models`",
@@ -619,12 +714,18 @@ atlas_validation_prompt <- function(dir) {
619714

620715
atlas_task_prompt <- function(data, meta) {
621716
profile <- utils::capture.output(utils::str(data, list.len = 50))
622-
paste(
717+
# unlist() drops the NULLs from inactive sections, so no blank gaps
718+
parts <- list(
623719
sprintf("Build up to %d models predicting `%s` from the other columns.",
624720
meta$n_models, meta$outcome),
625721
sprintf("The data.frame `data` has %d rows and %d columns:",
626722
nrow(data), ncol(data)),
627723
paste(profile, collapse = "\n"),
724+
if (isTRUE(meta$test_prop > 0)) paste0(
725+
"A further ", round(meta$test_prop * 100), "% of rows has been held ",
726+
"out as a final test set that you will NEVER see. Every final model ",
727+
"is evaluated on it after the run, so optimise for genuine ",
728+
"generalisation, not for your own validation score."),
628729
if (length(meta$exclude) > 0) paste0(
629730
"The user excluded these columns (unavailable at prediction time, or ",
630731
"leakage risk); they have already been removed from `data`: ",
@@ -648,9 +749,9 @@ atlas_task_prompt <- function(data, meta) {
648749
function(ci) is.null(ci$check), logical(1)),
649750
"", " (machine-checked)")),
650751
collapse = "\n")),
651-
if (!is.null(meta$goal)) paste("Additional instructions:", meta$goal),
652-
sep = "\n\n"
752+
if (!is.null(meta$goal)) paste("Additional instructions:", meta$goal)
653753
)
754+
paste(unlist(parts), collapse = "\n\n")
654755
}
655756

656757
atlas_fix_prompt <- function(fails) {

README.Rmd

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,25 @@ all — `exclude` removes them before the agent ever sees it, and
9797
`atlas_leakage_screen()` automatically flags predictors that alone explain
9898
almost all of the outcome. See `vignette("constraints")`.
9999

100+
## Unattended runs
101+
102+
For hands-off experimentation — overnight, in a script, on a schedule — set
103+
`autonomous = TRUE`: the agent states its plan and proceeds instead of
104+
waiting for approval, iterating keep/discard experiments under the stopping
105+
rules. Pair it with `test_prop` to hold out rows the agent **never sees**;
106+
when the run ends, Atlas itself evaluates every surviving model on that
107+
test set, so the final ranking can't be gamed by overfitting the agent's
108+
own validation scheme:
109+
110+
```{r}
111+
res <- atlas(claims, "severity",
112+
autonomous = TRUE,
113+
n_models = 10, patience = 8, # room to explore
114+
test_prop = 0.2) # the ungameable judge
115+
116+
res$test_leaderboard # held-out performance, best first
117+
```
118+
100119
## Sessions
101120

102121
`atlas()` is a one-call wrapper around an `AtlasSession`, which lives in the

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,25 @@ all — `exclude` removes them before the agent ever sees it, and
9393
`atlas_leakage_screen()` automatically flags predictors that alone
9494
explain almost all of the outcome. See `vignette("constraints")`.
9595

96+
## Unattended runs
97+
98+
For hands-off experimentation — overnight, in a script, on a schedule —
99+
set `autonomous = TRUE`: the agent states its plan and proceeds instead
100+
of waiting for approval, iterating keep/discard experiments under the
101+
stopping rules. Pair it with `test_prop` to hold out rows the agent
102+
**never sees**; when the run ends, Atlas itself evaluates every
103+
surviving model on that test set, so the final ranking can’t be gamed by
104+
overfitting the agent’s own validation scheme:
105+
106+
``` r
107+
res <- atlas(claims, "severity",
108+
autonomous = TRUE,
109+
n_models = 10, patience = 8, # room to explore
110+
test_prop = 0.2) # the ungameable judge
111+
112+
res$test_leaderboard # held-out performance, best first
113+
```
114+
96115
## Sessions
97116

98117
`atlas()` is a one-call wrapper around an `AtlasSession`, which lives in

0 commit comments

Comments
 (0)