tidylearn 0.5.0.9000
(Development version.)
Breaking Changes
- tidylearn requires R 4.1.0 or later. The package, its README and the vignettes use the native
|>pipe, which R 3.6 and 4.0 cannot parse. magrittr’s%>%is still re-exported, so existing code that pipes with it afterlibrary(tidylearn)keeps working.
New Features
-
tl_coefficients()returns a model’s coefficients as a tibble, with standard errors, test statistics, p-values and – withconf_int = TRUE– a confidence interval. It covers"linear","polynomial","logistic","ridge","lasso"and"elastic_net". Until now the only coefficient output wastl_table_coefficients(), which needs the suggestedgtpackage installed, returns a formatted table rather than data, and carried no interval at all — so getting a slope and its interval meant reaching intomodel$fitand callingconfint()by hand.exponentiate = TRUEreports odds ratios for a classification model. The standard error stays on the log-odds scale it was computed on and is renamedstd_error_log, becauseexp()of a standard error is not the standard error ofexp(estimate).Intervals are Wald intervals, built from the standard errors reported alongside them, so the interval and the p-value in a row always agree about whether zero is excluded. For
"linear"and"polynomial"they matchconfint()on the underlyinglmexactly. For"logistic"they use the z the summary reports rather than profiling the likelihood, whichconfint(model$fit)still gives you.Regularised methods return the estimates and the
lambdathey came from.conf_int = TRUEis an error there rather than a column ofNA: glmnet reports no standard errors, and a Wald interval on a shrunken estimate would not cover at its stated rate. A multiclass regularised model has a set of coefficients per class, returned under aclasscolumn and grouped by class in the table.exponentiateis refused there: glmnet’s multinomial coefficients are not relative to a reference class, so their exponent is not an odds ratio. tl_table_coefficients()gainedconf_int,levelandexponentiate, passed through totl_coefficients(). Existing calls produce the same table as before, apart from thelambdachange below. Withexponentiate = TRUEa regularised table ranks terms by the size of the log odds ratio: ranked by the odds ratio itself, a term the penalty dropped (1) sorted above a strong negative effect (0.02).An unrecognised
lambdais now rejected by name. Anything that was not"1se"or"min"used to reachglmnet::coef()as a penalty, so a typo such as"1SE"failed withnon-numeric argument to binary operatorand a vector of penalties withthe condition has length > 1, neither of which names the argument at fault. A numericlambdaoutside the penalties the model was fitted over is refused as well. glmnet returns the coefficients at the nearest end of the path for it, which the table labelled with the penalty asked for:lambda = 0returned the coefficients at the smallest penalty on the path, labelled as unpenalised. The same penalty checks apply to regularised importance.tl_coefficients()takes no..., so a misspelt argument such as broom’sconf.int = TRUEis an error rather than a table without an interval.
Bug Fixes
predict()on a"ridge","lasso"or"elastic_net"model now useslambda_1se, so its predictions change. It usedlambda_min, whiletl_coefficients(),tl_table_coefficients()and importance all reportlambda_1seby default, so the coefficients on display were not the ones behind the predictions.lambda_1seis the penalty the fit itself was documented as preferring for generalisation. Metrics, cross- validation and tuning scores for these methods move with it.tl_split(stratify = )no longer corrupts the split when a stratum holds a single row.sample()on one number draws from1:n, so that stratum drew a row from elsewhere in the data – sometimes one already drawn – and left its own row in test. Onmtcarsstratified bympg, most of whose values occur once, the split returned 43 rows from 32.tl_compare_cv()refits each model with the arguments it was built with. It refitted from the formula and method alone, so every model was scored at its method’s defaults: a tree withcp = 0.5and one withcp = 0.0001, minsplit = 2produced identical fold scores. Models now record their fitting arguments in$spec$args; arguments passed totl_compare_cv()override them. A model fitted withweights,subset,offset,foldidorstratais refused, since those hold one value per training row and cannot follow the rows into a fold. Those are recorded by name only, in$spec$per_row_args, so a model does not carry a second copy of its weights.tl_step_selection()withdirection = "forward"or"both"returned the intercept-only model for any formula using..step()expanded the dot against the starting model’s1, which left no terms to add. The formula is now expanded against the data first.tl_semisupervised()refuses a numeric response. Labels are propagated by majority vote within a cluster, and the response was passed throughfactor(), sompg ~ .was fitted as a classification with one class per distinct value.tl_semisupervised()warns about rows whose cluster holds no labelled observation, and leaves them out of training. They were givenNAlabels and dropped when the model was fitted, without a message; with six labels from two iris classes, 53 of 150 rows went that way. The count is in$semisupervised_info$n_unlabelled_dropped. A logicallabeled_indicesand a string formula are also accepted now; the logical was matched as the positions 0 and 1.tl_anomaly_aware(action = "downweight")now changes the model. With the default"tree"the weights went torpart.control(), which ignored them, so the fit was identical to the unweighted tree; with"linear"the call errored with..1 used in an incorrect context. It now requires a method that applies case weights –"linear","polynomial","logistic","tree","ridge","lasso","elastic_net"or"forest"– and refuses the others, of which"boost"and"nn"errored,"xgboost"warned, and"svm"ignored the weights. A forest reads them as sampling weights.tl_model()passesweightsthrough tolm(),glm()andrpart(). For"linear"and"logistic"it failed with..1 used in an incorrect context. For"tree", every extra argument went torpart.control(), which discards what it does not recognise, soweights,parms,costand a wholecontrollist were accepted and had no effect. Anoffsetargument is refused with a pointer tooffset()in the formula, which is the formpredict()can apply to new data.tl_plot_model_comparison()andtl_table_comparison()keep two models of the same method apart. Both got the same default name ("linear (reg)"in the table), so the plot drew one bar over the other and the table pivoted the pair into list cells. Repeated default names are now numbered, andtl_table_comparison()checksnameshas one unique entry per model, as the plot already checked its length.tl_split(stratify = )splits rows whose stratify value is missing.split()drops anNAgroup, so those rows were never drawn and all went to test. They now form a stratum of their own.tl_split()on a one-column data frame also returns data frames rather than bare vectors.-
tl_prepare_data()imputes with the method it names."mode"and"knn"both used the mean while the message reported the method asked for;"mode"is now implemented and anything else is refused. A missing value in a factor or character column was never imputed, and with more than two levels it broke one-hot encoding with `Can’t recycle ..1 (size- to match ..2 (size 148)`. Categorical gaps now take the column’s most frequent value.
tl_prepare_data(remove_correlated = TRUE)removes the feature that clears a correlated group rather than the ones around it. Every pair was decided separately against a half-zeroed matrix, so for a chainx1 - x2 - x3it droppedx1andx3and keptx2, the one feature correlated with both. Features are now removed one at a time, the most correlated pair first, ascaret::findCorrelation()does.tl_prepare_data()processes only the formula’s predictors. It read the formula for the response alone, soy ~ . - idone-hot encodedidinto twenty columns. A column the formula excludes is now returned unchanged.tl_model()shares that formula reading to record the training levels of factor predictors, and for a formula using.it recorded none.tl_add_cluster_features(method = "hclust")takesk. It was passed on to the tree fit, which has nok, so every value but the fallback failed withunused argument (k = 4).tl_semisupervised(cluster_method = "hclust")failed the same way on every call, and now cuts the tree.tl_anomaly_aware(action = "flag")adds the flag to the formula as written. It rebuilt the formula from its variable names, soSpecies ~ . - Sepal.WidthputSepal.Widthback in as a predictor andmpg ~ poly(wt, 2)was fitted asmpg ~ wt.predict()on atl_stratified_models()result works on data without the response column. It selected the response out by name and failed withElement mpg doesn't exist.tl_step_selection()withdirection = "forward"or"both"keeps a transformed response. The starting model was built from the response’s variable name, solog(mpg) ~ wt + hp + qsecselected a model ofmpg.tl_compare_cv()refuses repeated model names and names an unnamed entryModel_<i>, numbered on (Model_1.1) if the caller already chose that name. Results are keyed on the name, solist(a = m1, a = m2)pooled both models’ folds into one summary row, and a partly named list produced a model called"".tl_plot_lift()andtl_plot_gain()no longer depend on row order. Rows with tied probabilities kept the order they arrived in, and a tree scores many rows alike: for a tree ofSpecies ~ Sepal.Widthon two iris classes, reversing the rows moved the gain at 10% of the population from 0% to 20% of responders. Each row now counts the response rate of its tie group, the value any tie-breaking gives on average. A row missing its response turned every point toNAand left the chart empty; those rows are now left out, with a warning. The bins also matchbins: sizing them by rounding up gave 32 rows in 10 bins as 8, and abinsthat is not a whole number of at least 1 is refused.Importance for
"ridge","lasso"and"elastic_net"no longer depends on the units of each predictor. It was the absolute coefficient, so rescalinghpto hundreds multiplied its importance about a hundredfold without changing a prediction. Each coefficient is now multiplied by its predictor’s standard deviation. Importance values for these methods change, intl_table_importance(),tl_plot_importance_regularized()andtl_plot_importance_comparison(). A multiclass model, which failed withnon-numeric argument to mathematical function, now takes each predictor’s largest value across classes. A penalty that drops every predictor is reported as such rather than as an empty table and amax()warning.tl_plot_importance_comparison()counts a feature a model did not use as zero. The ranking averaged each feature over only the models that kept it, so a feature a lasso dropped could outrank one both models used.tl_table_confusion()warns when rows are missing a response or a prediction.table()dropped them, and the counts summed to fewer rows than were passed in without saying so.tl_tune_grid()andtl_tune_random()treatmethod = "logistic"as classification whatever type the response is stored as. They checked only for a factor or character response, so a 0/1 numeric response defaulted to"rmse", which a logistic model never produces, and the search failed withMetric "rmse" was not produced for this task.A parameter set that fails on some folds can no longer be chosen as best. Its
mean_metricwas averaged over whichever folds survived, so it could win on the easier folds alone, and nothing in the results said so. The results now carry ann_folds_okcolumn, and only sets scored on every fold are eligible. If no set completes every fold, the best of the most complete sets is used, with a warning naming it. When every set fails in every fold the tuners stop with a message saying so, rather thanargument is of length zero.tl_tune_random()uses a single value as given.sample(20, 1)draws from1:20, soparam_space = list(minsplit = 20, cp = c(0.01, 0.1))triedminsplitvalues of 3, 10 and 6 withseed = 1. A logical was drawn fromc(TRUE, FALSE)whatever was supplied, soimportance = TRUEcame outFALSEin three draws of four. Logicals are now drawn from the values given.tl_tune_random()also accepts a list of candidates, such ashidden_layers = list(10, c(20, 10)), andverbose = TRUEno longer fails on a character parameter.Vector-valued grid candidates reach the model. The grid stores them in a list column, so the default
"deep"grid passedhidden_layers = list(c(10, 5))and every multi-layer candidate failed.tl_default_param_grid("logistic")returned the ridgelambdagrid, whichglm()does not accept, so every fit in a search over it failed. It now returns an empty grid with a warning pointing to"ridge","lasso"and"elastic_net".The large
"forest"grid no longer includessampsize, which randomForest reads as a number of rows and the grid gave as fractions from 0.5 to 1. Formethod = "forest"the tuners capmtryat the number of predictors, with a warning: randomForest reset an oversizedmtryin every fold while the results credited the value asked for.tl_auto_interactions()honoursexclude_vars. It computed the reduced predictor set and never used it, so with a stronga:zeffectexclude_vars = "z"still returnedy ~ a + b + z + a:z. A name inexclude_varsthat is not a predictor in the formula is now an error. With ay ~ .formula the function failed in its testing step, and now works. When every pair is already in the formula it returns the model as specified, with a message.tl_interaction_effects()reportsfit,lower,upperandslopeon the response scale whether or notintervals = TRUE. With intervals a logistic model’s values were log odds (am ~ wt * hpgavefitfrom -32 to 40) and without them probabilities. A glm’s interval is now built on the link scale and transformed back, and a linear model’s is the t-based intervalpredict.lm()gives. Thesecolumn is removed; for a glm it was on the link scale. A model with no standard errors, such as a tree, failed with$ operator is invalid for atomic vectorsand now returns point estimates with a message.tl_interaction_effects()andtl_plot_interaction()accept a model fitted withy ~ ., which failed withVariables not found in model formula.tl_interaction_effects()also stopped repeating grid blocks when quartiles ofby_vartie:mpg ~ wt * cylgave five slopes for three values ofcyl, and now gives one each, labelled with the quartiles it stands for.tl_test_interactions()accepts a.formula and a string formula, and stops with a message naming the formula’s predictors when a type filter leaves no pairs. These failed with messages such asundefined columns selectedandargument 1 is not a vector. It no longer re-tests a pair whose interaction the formula already has, which came back as a row ofNA, and it refuses a formula with no response, whose first predictor it treated as the response.The interaction functions keep an
offset()in the formula. Rebuilding the formula from its terms dropped it, sotl_test_interactions()andtl_auto_interactions()tested and refitted models without the offset, andtl_interaction_effects()failed withobject 'disp' not found.tl_plot_interaction()on ay ~ . - xmodel failed the same way, because its prediction grid left out the removed column the model still evaluates.tl_interaction_effects()refuses a constantvar, which failed withsubscript out of bounds, andtl_plot_interaction()refuses atypeargument:type = "class"drew class codes over a probability band.tl_plot_interaction(confidence = TRUE)draws the confidence band. The band came frompredict(), which returns no interval, so none was ever drawn. It now comes from thelmorglmfit, for numeric by categorical plots; a model without standard errors gets a message.tl_split()refuses apropoutside (0, 1).prop = 1.5split 32 rows 31/1, because each side is kept to at least one row.tl_step_selection(criterion = "BIC")penalises by the rows the model used.log(nrow(data))counted rowslm()dropped for missing values, which could change the model selected. Forward and"both"selection also find a variable the formula takes from the caller’s environment.Variable importance works for more models. A forest fitted with
importance = FALSEfailed withsubscript out of bounds; it now uses the impurity measure the forest does have.tl_table_importance(), which documented xgboost support, refused xgboost models; they now report gain.tl_plot_importance()shares the table’s extraction, and the dashboard’s importance panel, which showed an error for"ridge","lasso"and"elastic_net", now plots them.tl_plot_importance_comparison()with no supported model failed inside dplyr; it now says so.predict()on atl_stratified_models()result returns what each cluster’s model returns for the requestedtype. Withtype = "prob"it returned only.cluster, with a warning per row. Class levels and probability columns follow the classes in the training data: they had followed whichever rows came first, so the second level – the positive class – changed with row order, and a class one cluster never saw had probabilityNArather than 0.tl_anomaly_aware()names an invalidaction, which failed withobject 'model' not found, and no longer documents an"isolation_forest"method it never had.tl_default_param_grid()gives"polynomial"adegreegrid, and says"linear"has nothing to tune; both used to warn “Unknown method”.tl_tune_grid()andtl_tune_random()name a parameter given no candidate values, which was reported as every set failing or asinvalid first argument. Intl_tune_random()a whole-number range with equal ends,c(20, 20), is the value 20 – it drew from 1 to 20 – and a non-whole one is refused with a message that no longer advises writingc(20.5, 20.5)asc(20.5, 20.5). A forestmtrybelow 1 is raised to 1, as randomForest does, so the results report the value used.tl_plot_tuning_results()on a one-parameter search explains that its default plot needs two parameters, where the message saidplot_typemust be one of “scatter”, … and had got “scatter”.The cluster table no longer counts dbscan’s noise points as a cluster, averages around missing values in hclust and dbscan tables, and a long formula no longer splits a table’s source note in two.
tl_table_coefficients()warns about arguments it does not use.tl_prepare_data(remove_zero_variance = FALSE)no longer stops on an entirely missing numeric column, and its documentation says the statistics come from the data passed in, so preparing before splitting lets test rows shape them.tl_coefficients()andtl_table_coefficients()report a term the fit could not estimate — one of two collinear predictors, or an interaction of factors with a combination no row has — as a row with anNAestimate.summary()drops aliased terms from its coefficient matrix, sotl_table_coefficients()omitted them silently:mpg ~ wt + wt_doubledproduced a two-row table for a three-term formula, with nothing to show the third term had ever been there. A fit of full rank is unaffected.tl_plot_regularization_path()drew its feature labels on top of the paths they name, in the same colour, so they were unreadable – and two coefficients that end close together printed one label over the other. Onmtcarsthat hiddratbehindqsecand clippedamandwtat the panel edge. Labels now sit clear of the leftmost point, spread far enough apart to read, each with a leader line back to its own path.
Documentation
The hex logo and the pkgdown favicons have sharp corners, in line with other R package hex stickers. The artwork is otherwise unchanged.
PACKAGE_ARCHITECTURE.md’s four links into the README were 404s on the documentation site. pkgdown rewroteREADME.md#...toREADME.html, a page it never builds, because it publishes the README as the site’s home page. They now point at the home page’s sections directly, which also works when the file is read on GitHub.
tidylearn 0.5.0
CRAN release: 2026-09-02
New Features
Cloud compute (security guards)
tl_cloud_consent()— grants or revokes permission for the rest of the R session to upload training data to your Modal account. Cloud fits otherwise requireconfirm_upload = TRUEon every call. The lock is never written to disk and does not survive an R restart, and tidylearn never prompts interactively, so scripts and CI behave the same as an interactive session.Cloud endpoints are read from the
TIDYLEARN_MODAL_ENDPOINTenvironment variable and validated before any request is built: the scheme must behttpsand the host must be on the allowlist. Lookalikes such asmodal.run.example.comorevil-modal.runare rejected. The endpoint is user-supplied configuration, so this check is what stops a typo or a modified variable sending training data somewhere other than Modal. An environment variable is used rather than an R option because an option can be set silently by a shared.Rprofile.-
tl_cloud_allow_host()andtl_cloud_allowed_hosts()— the allowlist defaults to Modal’s own domains, and Modal customers serving Web Functions from a custom domain can extend it. Extension is a per-session call rather than an option or environment variable, for the same reason: nothing inherited from the environment should be able to add an upload destination. Added hosts must be bare host names, and a single label such as"com"is refused because it would open an entire top-level domain.These implement T2 and T9 of
system.file("security/threat-model.md", package = "tidylearn"). Submission itself is still not wired up —compute = "cloud"continues to error.
Cloud compute (model serialisation)
-
Internal helpers now convert a fitted model to bytes and back for transport from a remote worker. Twelve of the thirteen supervised methods survive base R serialisation unchanged, xgboost included — its booster is embedded in the byte stream rather than left as a dangling pointer.
method = "deep"is the exception and is handled separately: a keras model is a reference to a Python object and cannot cross a process boundary that way, so its weights travel as their own hdf5 payload viakeras::serialize_model(). Detection is by the presence of a Python object rather than by method name or keras class, because keras renamed its classes between versions and matching those would silently stop detecting models on one side of the change.
Bug Fixes
Several of these changed reported numbers. Results produced by 0.4.0 and earlier should be recomputed.
Degenerate inputs
tl_model(method = "forest")hung indefinitely on a classification response whose predictors were all constant. randomForest’s classification path keeps drawingmtrycandidates looking for a split that cannot exist, and the loop is C-level, so it ignored interrupts and the session had to be killed. It was reachable throughtl_pipeline(), whose default candidates include a forest, and throughtl_auto_ml(), whose baselines do. Now refused before the call, naming the columns. Regression is unaffected and still fits, as does a frame where only some predictors are constant. The predictor set is read throughterms(), so a.is expanded against the data and an exclusion such asy ~ . - idis honoured.A character specification such as
"Species ~ ."was read as a regression problem by every entry point excepttl_model().tl_model_supervised()coerces withas.formula(), but that happens aftertl_pipeline(),tl_prepare_data(),tl_cv(),tl_auto_ml()and the two tuners have already calledall.vars(formula)[1]– andall.vars()on a string ischaracter(0), so the response name came backNAanddata[[NA]]wasNULL.tl_auto_ml(iris, "Species ~ .")announced “task: regression” and returned an unranked leaderboard;tl_pipeline()scored a classification tree withrmseand returned a pipeline whose every metric wasNA, warning only that the values were missing. Coercion now happens at each entry point, before anything reads the formula, and an argument that is neither a formula nor a string that parses as one is refused by name.A repeated name in
tl_pipeline(models = ...)silently discarded a model. The training loop indexesmodels[[model_name]], which resolves to the first match, solist(a = tree, a = forest)fitted the tree twice and never fitted the forest. Repeated names are now refused, next to the existing guard for unnamed ones.Malformed entries in
modelsreported base R internals that named neither the model nor the mistake: a spec with nomethodgave “missing value where TRUE/FALSE needed”, a spec that was not a list gave “$ operator is invalid for atomic vectors”, a two-elementmethodgave “‘length = 2’ in coercion to ‘logical(1)’”, and an unsupervised method gave “undefined columns selected”. Each is now checked before the run starts and names the offending model.evaluation$cv_foldsandevaluation$train_propwere unvalidated.train_prop = 0reached base R as “result would be too long a vector”,train_prop = 1surfaced as a ROCR complaint about class counts, andtrain_prop = 1.5as “cannot take a sample larger than the population”; bad fold counts arrived as rsample errors namingv, which is not an argument of anything the caller wrote. Both are now checked against their own names, and both are checked again against the row count when the pipeline runs: more folds than rows is reported as such, and so is atrain_propthat is in range but rounds to an empty side on a small frame.evaluation$validationandevaluation$best_metricwere in the same position one step earlier – set toNULLthey were dropped from the list and reached%in%as “argument is of length zero” – and are now reported by name too.An unrecognised name in
evaluation$metricswas accepted, computed nothing, and left the run warning that all values wereNA– the symptom rather than the cause. Unknown metrics are now refused with the list of available ones, matching whattl_tune_grid()already did. An empty metric set is refused too; it previously rendered thebest_metricerror as a bare full stop. The list is judged against the task the pipeline will actually fit,tl_model()’s logistic rule included:method = "logistic"on a 0/1 integer response fits a classification model and each fold reports classification metrics, soaccuracyandaucare accepted there.A numeric response with both logistic and any other supervised method among the candidates gave one run two tasks. The leaderboard holds one set of metrics, so whichever way they were chosen the other models scored
NAand dropped out of the comparison silently –logisticpluslinearon a 0/1 column ranked logistic at 0.6998 and reported nothing at all for linear. The mixture is now refused where the models are read, naming the methods on each side. A factor response is unaffected: there is one task there, and mixing methods is the ordinary case.A response that is not a column of
data– a typo in the formula – read asNULLintl_pipeline(), which set regression defaults and failed several steps later inside rpart with “object ‘Speces’ not found”. It is now refused where the formula is read, listing the columns that are there.An intercept-only formula (
y ~ 1) reachedtl_run_pipeline()and failed with “result would be too long a vector”. A pipeline preprocesses and scores predictors, so it now says it needs at least one.A single-class response was named plainly only by logistic regression. Every other classification method reported whatever its backend hit first: rpart “number of rows of matrices must match (see arg 2)”, glmnet “non-conformable arguments”, e1071 “Model is empty!”, xgboost a complaint about
num_class. Ten of the thirteen supervised methods now give the same message, naming the response and the class it holds.linearandpolynomialkeep the numeric-response message they already had, which now says the response holds a single class rather than offering classification methods that would refuse it in turn, and logistic regression keeps its own wording.method = "forest"andmethod = "svm"derived their defaults from the number of columns in the frame rather than the number of predictors in the formula, so an explicit formula over a wider frame got the wrong one.mpg ~ wt + hponmtcarsasked randomForest formtry = 3of 2 predictors, which it reset with a warning, and asked e1071 for a kernel width of 1/10 instead of 1/2, with nothing said at all.Species ~ Sepal.Length + Sepal.Widthonirisasked formtry = 2of 2, also in silence – every predictor sampled at every split, which is bagging rather than a random forest. Neither default is computed now: where the caller and the tuner leave the argument unset, it is left unset, and the wrapped package applies the same default it documents. Ay ~ .formula is unaffected, which is why this survived. The 0.3.0 entry below took the response column out of the SVM count; what remained was every other column in the frame. Leaving an argument out takesdo.call(), which evaluates before it builds the call, so thematch.call()these backends run recorded the training frame as a literal:print(model$fit)spilled every row, and on a 960-row frame the stored call alone was 159 Kb of a 1.5 Mb forest. Thedataargument is put back to a symbol after the fit.The
$fitslot was documented as the wrapped object throughout. That holds for a supervised method; an unsupervised one returns tidied components as well, so its$fitis the list holding them and the wrapped object is at$fit$model. Corrected intl_model(), theDescriptionfield, the README, the architecture notes, and the getting-started, unsupervised and integration vignettes.tl_read_s3()raised “subscript out of bounds” for a zero-length or multi-elementsource, the one malformed input that missed its own “Invalid S3 URI” message.
Metrics and evaluation
tl_cv()explains a metric that no fold could compute rather than leaving a bareNaNin the summary.folds = nrow(data)is leave-one-out, so every test fold holds one observation andrsq— which needs variation in the truth — is undefined;mean()over nothing then reportedNaN, which reads as a malfunction rather than as a property of the request.rmseandmaeare defined for a single observation and are unaffected.tl_cv()no longer repeatstl_model()’s notes once per fold. The note that a numeric response with few distinct values is being treated as regression is about the data, not the fold, and appeared k times.tl_calc_classification_metrics()computed precision, recall, sensitivity, specificity and F1 for the wrong class. Theyardstickcalls omittedevent_level, so they defaulted to the first factor level while the rest of the package — AUC, class prediction, lift and gain — treats the second level as positive. A binary model predicting only positives reported specificity 1.0 where the true value is 0.0. Threshold metrics fromtl_evaluate_thresholds()were affected the same way, so reported precision fell as the threshold rose. Multiclass metrics were never affected.tl_cv()never evaluated the lastn %% foldsobservations: folds were sized withfloor(n / folds)and sliced forward, leaving the remainder in every training set and no test set. Onmtcarswithfolds = 5, 30 of 32 rows were scored. Rows are now assigned to folds so that the folds partition the data and differ in size by at most one.tl_cv()also rejects fold counts below 2 or abovenrow(data).tl_check_assumptions()tested linearity withcor(fitted, residuals), which is identically zero for any OLS fit with an intercept — the check could only ever report SATISFIED. It is now a RESET-style test on powers of the fitted values.
Fitting
-
"ridge","lasso"and"elastic_net"no longer fail when a predictor has a missing value. The response was read fromdatawhile the design matrix came frommodel.frame(), which appliesna.omit— so a single missing predictor leftyone row longer thanx, and glmnet reported “number of observations in y (60) not equal to the number of rows of x (59)”. That names neither missing values nor the column responsible, and reads as though the caller had passed mismatched inputs. The response is now taken from the same model frame that builds the design matrix, so these methods drop the incomplete row and carry on, aslm(),rpart(),nnet()andsvm()already did.
Prediction
-
Classification now reduces the response to the classes it contains. A subset keeps every factor level, so
iris[iris$Species != "setosa", ]holds two classes and declares three, and that frame broke seven of the eight classification methods in seven different ways:randomForestandglmnetrefused to fit,gbmandnnetfailed atpredict()ortl_evaluate(), andrpartreturned a probability column for the class that was not there. Worst of the set,tl_calc_classification_metrics()read the declared level count when deciding whether the problem was binary, so it stopped passingevent_leveland letyardstickscore the first class as positive — silently reopening, for any such response, the metric defect fixed above.The fitted models were never wrong:
glm()and the rest drop an empty level internally, so the coefficients always matched the explicitly dropped frame. Only tidylearn’s description of them was wrong. The response is normalised once intl_model(), so the specification, the fit and every predict path now agree, and metrics from a subset match those fromdroplevels()exactly. tl_model(method = "logistic")records a classification model when the response is stored as something other than a factor. A 0/1 integer response produced a binomialglm()described by a specification that saidis_classification = FALSE, sotl_evaluate()scored it withrmse,maeandrsq— and asking it foraccuracyreturned an empty tibble, with no error and no warning.-
predict()failed or returned wrong output for six method-and-task combinations, all now fixed and covered by a contract test that runs every method through the same grid:- Multiclass
"boost"returned a single prediction for the whole input, becausepredict.gbmhands back a 3-D array thatis.matrix()does not recognise.type = "prob"errored for any input with more than one row. -
"svm"withtype = "prob"always errored: the fitted object records the flag as$compprob, not$probability. - Binary classification with
method = "nn"could not fit at all —entropywas passed explicitly and collided with the valuennet.formula()supplies itself. -
"xgboost"built its design matrix from the full two-sided formula, so scoring data without the response column was impossible. -
"svm"and"xgboost"silently dropped rows with missing predictors, returning a shorter vector so that predictions no longer lined up with the input rows. - Multinomial
"ridge"/"lasso"/"elastic_net"withtype = "prob"errored on single-row input.
The
nnfailure is worth its own note:nnet.formula()suppliesentropy = TRUEitself when the response is a two-level factor, andtl_fit_nn()named it again, sonnet.default()received it twice and reported “formal argument ‘entropy’ matched by multiple actual arguments”. Three or more classes were unaffected, becausennet.formula()usessoftmaxthere andnnet.default()setsentropytoFALSEwheneversoftmaxis on — so the argument it collided with was never present. The criterion is now left to nnet. Neural networks had no test coverage at all; there are now four tests beyond the contract grid. - Multiclass
predict()on atl_auto_ml()model fitted with engineered features no longer errors on raw new data. Four of the eight candidates a typical search produces — thepca_*andclustered_*variants — were fitted on columns that exist only inside the search, so predicting on a held-out set failed with “object ‘PC1’ not found” or “object ‘cluster_kmeans’ not found”. Whenever one of those won the leaderboard,predict(result$best_model, new_data = ...)was unusable. Each variant now records the transformation that produced its features, andpredict()replays it — fitted on the training data — before dispatching.predict()on a k-means model matchednew_datato the cluster centres by position, taking every numeric column in whatever order it arrived. A mismatched width was recycled rather than rejected, producing cluster numbers that looked valid and were not; a reordered frame silently measured distance against the wrong centres. Columns are now matched by name, and a missing or non-numeric column is an error naming the column.predict()on a PCA model had the same defect and now alignsnew_datato the training predictors by name.tl_reduce_dimensions(n_components = k)trimmed its returned data tokcomponents but left the reduction model projecting onto all of them, sopredict(result$reduction_model, new_data)returned a wider matrix than the model trained on$datacould consume. The component budget is now recorded on the model and honoured bypredict().XGBoost prediction pins the training factor levels, so new data missing a level no longer changes the contrast coding, and no longer passes
ntreelimitorreshapetoxgboost::predict(). Both are deprecated upstream and warn that they will become errors; every XGBoost prediction emitted two warnings per call.tl_predict_xgboost()gainsiterationrangeand acceptsntreelimitwith a deprecation warning that translates it. Multiclass probabilities are reshaped to one named column per class whichever shape the installed xgboost returns.
Data leakage
tl_pipeline()learned imputation medians and standardisation centres and scales from the whole dataset and only then split, so every assessment row helped define the transformation it was scored under. Each fold, and each side of a train/test split, now learns its own statistics. The final model still uses the full-data statistics, whichtl_predict_pipeline()continues to replay.tl_pipeline()also imputed the response, replacing missing outcomes with the median and turning them into both training targets and evaluation ground truth. Imputation now skips the response.tl_auto_ml()fitted PCA rotations and cluster centroids on all rows before cross-validating on the transformed data, so thepca_*andclustered_*candidates competed against honestly scored baselines. Both are now refitted inside each fold, via a newtransformargument totl_cv().
Ranking, splitting and tuning
tl_tune_xgboost()also refused a grid naming a single parameter.expand.grid()of one parameter is a single-column data frame, and[i, ]on one of those drops to a bare vector with the column name gone, so the parameters reached xgboost unnamed and it stopped with “parameter names cannot be empty strings”.tl_tune_grid()andtl_tune_random()had the same slip fixed for 0.4.0; this call site was missed.tl_tune_xgboost()could not complete a run. It readbest_iterationfrom the top level of thexgb.cv()result, which is where xgboost kept it before 3.0 and not where it has been since, so every parameter set scoredNULL,which.min()over those scores returnedinteger(0), and the call died on “attempt to select less than one element in get1index” — on the documented default call, for any input. Both locations are now read. Separately,nroundswas hardcoded at 1000 inside the function while...was forwarded to the same call, so passing the one argument an xgboost tuner obviously takes gave “formal argument "nrounds" matched by multiple actual arguments”. It is a named argument now, documented as the ceiling early stopping works within. The function had no test; it has one now.tl_tune_random()rejects a parameter range written backwards.c(0.1, 0.001)instead ofc(0.001, 0.1)was sampled withrunif(1, 0.1, 0.001), which isNaN— and R only warns — so every iteration drewNaN, models were fitted withcp = NaN, andbest_paramswas reported asNaNwithout anything failing. Equal bounds and a non-positive lower bound on a log-uniform range are refused for the same reason.tl_tune_random()accepts a discrete set of numbers that are not whole. Only whole numbers reached the discrete branch, solist(cp = c(0.001, 0.01, 0.1))— the natural way to write candidate values for a parameter that is never an integer — was rejected as an “Unsupported parameter space definition”, whiletl_tune_grid()took the same vector without complaint.tl_tune_grid()andtl_tune_random()name a metric they cannot produce. Asking for"accuracy"on a regression task, or for a metric that does not exist, failed with “replacement has length zero” from the assignment that came up empty. The error now says which metric was asked for and lists what the task does produce.tl_tune_deep(learning_rates = )searched over a value that changed nothing. It passedoptimizer = optimizer_adam(learning_rate = )intotl_fit_deep(), which has no such formal, so the argument fell into...and was forwarded tokeras::fit()— by which point the model is compiled, andcompile()is what sets the optimizer. Every point on the grid therefore trained at the same rate, andbest_learning_ratewas whichever happened to score highest on noise.tl_fit_deep()gains alearning_rateargument that reachescompile(), and the final refit on the winning configuration uses it too.tl_tune_deep()reports when no configuration could be fitted. Each fit is wrapped individually, so a bad argument forwarded through...left everyval_lossasNA;which.min()then returnedinteger(0)and the function failed with “attempt to select less than one element in get1index”, which describes nothing.tl_auto_ml(metric = "mape")returned the model with the highest error as the best one —mapewas missing from the ascending-sort list. Unrecognised metrics now error rather than assume a direction.tl_auto_ml()also returnsbest_model_name.tl_split()could return an empty training set and an empty test set:floor(n * prop)can be zero, anddata[-integer(0), ]selects nothing. Every group now keeps at least one row on each side.tl_tune_random()ignored two documented parameter forms. Any two-element numeric was caught by the continuous branch first, so an integer range likec(100, 500)was sampled withrunif(); and the log-uniform formc(min, max, "log")is a character vector, so its branch was unreachable and the literal"log"could be sampled as a value.param_spaceis now fully documented.tl_pipeline()accepted a partialpreprocessingorevaluationlist and then failed insidetl_run_pipeline()with “argument is of length zero”. Both specifications now fill in their defaults for anything unnamed. An unrecognised name is an error rather than a step that silently does nothing, andevaluation$best_metricis checked againstevaluation$metrics.
Diagnostics
tl_check_assumptions(),tl_influence_measures()andtl_diagnostic_dashboard()no longer fail when a predictor has a missing value.lm()drops incomplete cases, soresiduals(),fitted()and every influence measure came back shorter thanmodel$data, and combining them raised “arguments imply differing number of rows: 60, 59” — which describes nothing the caller did.tl_influence_measures()numbers observations by their row in the training data. It used1:n, so after a dropped row every observation was attributed to its neighbour: with row 3 missing, what the table called observation 3 was row 4, and so on to the end.optimal_hclust_k(method = "gap")never ran.cluster::clusGap()requires its clustering function to return a list with aclusterelement andcutree()returns a bare integer vector, so every call failed with “$ operator is invalid for atomic vectors”. Two further faults sat behind that one and could not show themselves while it errored on the first call: the refit usedstats::dist(), whose default is Euclidean, so a model built with any other distance was scored against clusterings it would never produce; and a model built from adistobject has no observations to resample, which surfaced as “no applicable method for ‘select’ applied to an object of class NULL” rather than as an explanation. All three are fixed, and the last is now an error that says to refit from the data or use"silhouette", which works from distances alone.tidy_dbscan()converted adistinput withas.matrix()and passed it as coordinates, clustering each observation’s vector of distances rather than the dissimilarity. It also read a non-existent"core"attribute, so every point was reported as a non-core point.tidy_kmeans()lost its entire metrics tibble for the Lloyd, Forgy and MacQueen algorithms, which leaveifaultNULL.tidy_gower()documentedweightsas a named vector but indexed it positionally, applying weights to the wrong variables. Named weights are now matched by name, and a mismatched length errors.tidy_mds(method = "sammon")andmethod = "kruskal"passed MASS’s “zero or negative distance between objects i and j” straight through. The cause is duplicated rows, which the message does not say. Both now check first and name the offending pairs.tl_plot_cv_results()could not plottl_cv()output — it read$fold_metricsandmean_value, which are named$foldsandmean.Lift and gain charts indexed past the end of the data in their final deciles, corrupting the cumulative curve.
The outlier plot from
tl_detect_outliers()attached flags to the wrong observations whenever more than one variable was plotted.plot_distance_heatmap()sorted its axes alphabetically, moving the diagonal off the diagonal and discarding anycluster_order.Influence plots used unnamed colour vectors, so when every point was influential they all rendered in the “not influential” colour.
tl_plot_nn_architecture()failed on any neural network with a single output unit — every regression fit, and every two-class fit once those could be fitted at all.NeuralNetTools::plotnet()evaluatesmod_in$call$formulaon that branch, andnnet()records its call verbatim, so what it found was the symbolformularesolving tostats::formula: “cannot coerce type ‘closure’ to vector of type ‘character’”.tl_fit_nn()now substitutes the formula into the recorded call. Multiclass took the other branch, which is why the function’s own example passed.tl_plot_tuning_results(plot_type = "parallel")andtl_plot_regularization_path()used thesizeaesthetic on a line, which ggplot2 deprecated in 3.4.0 and which told the user to file a bug against tidylearn. Both uselinewidth.tidy_pca_biplot(color_by = )andplot_mds(color_by = )accepted only a column name, but the tibbles they draw from carry an identifier and the coordinates — there is nowhere for a grouping variable to live, so the documented use was unreachable. Both now also accept a vector as long as the data, and a name that cannot resolve is an error rather than a plot that fails when printed.tl_interaction_effects()emitted “essentially perfect fit” warnings fromsummary.lm(). The slope is estimated by regressing the model’s own fitted values on the grid, which for a linear model lie exactly on a line, so the warning was expected by construction and is no longer passed on. The documentation now says thatslopes$slope_sedescribes the fit to the prediction grid rather than the uncertainty of the marginal effect.
Data ingestion
tl_read_kaggle()no longer lets a dataset slug reach the shell as written. The slug was interpolated intosystem2(), which appliesshQuote()to the command and leaves the arguments alone, andtl_parse_kaggle_url()matched[^/]+/[^/]+$— which admits;,|, backticks and$(. The URL parser is also skipped entirely when the caller passes a bare string, so the slug was not necessarily anything Kaggle produced. A pasted dataset link was the vector. Slugs and file names are now validated against what Kaggle identifiers actually are, before interpolation and before the CLI is looked for, and caller-derived values are quoted — which also fixes a destination path containing spaces.tl_read_kaggle(file = NULL)downloaded into a sharedtempdir()and returned the newest matching data file, so a file left by an earlier call could be handed back as the requested dataset. Each download now gets its own directory, emptied first.tl_read_kaggle(type = "competition")returned no data. Competition downloads arrive zipped and that endpoint has no--unzipflag, so the search for a data file found none. Archives are unpacked first, and the search recurses.tl_read_zip(format = )forced one format onto every member. A zip holding a CSV and a JSON read the JSON as CSV and row-bound the result, producing a frame with a column named after the JSON’s first line and no error at all. When the archive holds more than one kind of data file,formatnow selects the members of that format.
Errors instead of misleading results
-
Unsupervised routines that cannot use missing values now say so, naming the columns and how many values are affected.
tidy_kmeans()andtidy_gap_stat()previously surfacedstats::kmeans()’s “NA/NaN/Inf in foreign function call (arg 1)”,tidy_pca()gaveprcomp()’s “infinite or missing values in ‘x’”, andcalc_wss(),optimal_clusters()andtidy_silhouette_analysis()loop over k withpurrr, which wrapped those again into “In index: 2. Caused by error indo_one()”. None of them named the column, the problem, or a way forward. Missing values are the most ordinary thing that can be wrong with a data set.The message points at
"pam"and"clara", which accept missing values. Those, along withtidy_dist(),tidy_gower(),tidy_mds()andtidy_hclust(), are unchanged — they handle missing values themselves, and guarding them would remove working behaviour rather than improve a message. tl_split()andtl_tune_random()no longer rewrite the session’s random stream. Both calledset.seed()when given aseed, so a function seeded for its own reproducibility was also deciding what every latersample()orrnorm()in the caller’s script returned — two scripts differing only in whether they passedseeddiverged everywhere downstream. The stream is restored on exit; the seed still does its own job.tidy_pca(method = "princomp")produced loadings that could not be used.princomp()returns a"loadings"object rather than a plain matrix, andtibble::as_tibble()read that as a single long vector — 16 values against 4 row names for a four-variable PCA — soget_pca_loadings()failed with “Can’t recycle..1(size 16) to match..3(size 4)”. The loadings now matchprcomp()’s, up to the sign convention.-
The method and the response now have to agree.
"linear"and"polynomial"need a numeric response and"logistic"needs exactly two classes; every other supervised method takes either. A mismatch is an error attl_model(), naming the methods that would fit.tl_model(iris, Species ~ ., method = "linear")previously succeeded.lm()estimates from a factor’s underlying integer codes — its coefficients are identical to regressing onas.integer(Species)— so the classes were treated as equally spaced points on a scale andpredict()returned numbers between them. Nothing failed at any stage, which made this quieter than the logistic case: there was no later error to work back from.In the other direction,
tl_model(mtcars, mpg ~ wt, method = "logistic")reported thatmpg“has 25 levels”, listed all of them, and recommended classification methods for what is plainly a regression problem. It now says the response is numeric with 25 distinct values and points at the regression methods, while still accepting a two-class response stored as 0/1. -
tl_model(method = "logistic")now errors when the response does not have exactly two levels.glm(family = binomial)accepts a three-level factor without complaint and fits the first level against the other two, sotl_model(iris, Species ~ ., method = "logistic")returned a model that looked fine and meant nothing. The failure surfaced three calls later, atpredict(type = "class")andtl_evaluate(), both of which reported only that multiclass logistic was “not implemented” — by which point the caller had no reason to suspect the method choice. The error is now raised at fit time and names the methods that do handle more than two classes. A single-level response is reported separately.tl_pipeline()offeredlogisticas a default candidate for any classification task, so without a matching guard a three-level response would now fail the whole pipeline rather than one model. It offerslogisticonly for a two-level response, astl_auto_ml()already did. -
tl_semisupervised(),tl_anomaly_aware(),tl_transfer_learning()andtl_stratified_models()default tosupervised_method = "tree". The first three defaulted to"logistic", which cannot fit a response with more than two levels or a numeric one, and the fourth to"linear", which fitslm()to a factor response and returns numbers rather than refusing.tl_anomaly_aware(iris, Species ~ ., response = "Species")— the function’s own documented example — was in the first group."tree"handles regression and classification, at any number of classes.This changes the model a call produces when
supervised_methodis not given. Pass it explicitly to keep the previous behaviour. tl_check_assumptions()andtl_influence_measures()advertised support for"ridge","lasso"and"elastic_net", but glmnet provides no residuals, hat values or influence measures. They now explain this instead of failing partway through.plot_cluster_comparison()andcreate_cluster_dashboard()calledgridExtrawithout arequireNamespace()guard.Database connection strings carried the password into the returned object’s
tl_sourceattribute — printed on everyprint()and persisted bysaveRDS()— into the progress message, and into the URL parse error. All are now redacted.tl_plot_tuning_results()names the validplot_typevalues in its error instead of reporting “Invalid plot_type or insufficient parameters”.get_pca_variance()andget_pca_loadings()accept a PCA model fromtl_model(method = "pca")as well as atidy_pca()object. The two representations carry the same tables under different names, and the accessors previously took only one of them.inst/examples/unified_workflow.Rreported “Reduced from 4 to 2 features” after requesting three components, and passedsupervised_method = "logistic"on three-class iris in three places, producing convergence warnings. It is now exercised bytests/testthat/test-examples.R, so it cannot drift again unnoticed.
Documentation
Every exported function now carries a runnable example. Thirteen had none:
tl_predict_pipeline(),tl_compare_pipeline_models(),tl_plot_cv_results(),tl_interaction_effects(),tl_plot_interaction(),tl_tune_nn(),tl_plot_nn_tuning(),tl_tune_xgboost(),tl_plot_xgboost_tree(),tl_plot_xgboost_shap_dependence()and the threeprintmethods. Writing them is what surfaced thetl_tune_xgboost()defects above.tl_plot_nn_tuning()documented the wrong input and the wrong plot. It takes the listtl_tune_nn()returns rather than a fitted model — the error message said so, the@paramdid not — and it draws a heatmap of the size-by-decay grid, not the training history its title claimed.DiagrammeRis now declared in Suggests.tl_plot_xgboost_tree()cannot render without it, reaching it throughxgboost::xgb.plot.tree().Corrected five factual errors across the docs: the README claimed ten articles where there are eleven;
compute-backendssaid eleven CPU-only methods and then listed ten, omitting"polynomial";integration-workflowsstill documentedtl_semisupervised()as defaulting tosupervised_method = "logistic"after it changed to"tree";tuning-and-pipelineswrote 6 x 3 = 19 fits; andCONTRIBUTING.mdgave its versioning worked example against 0.3.0, telling contributors to open a NEWS heading a release out of date.Rewrote
integration-workflowsandreporting, which had drifted from the register of the other nine articles. Their generic “Best Practices” and “Summary” sections are gone, each function now gets a sentence on what it buys you and what it costs, and the train-then-replay rule that governs all five integration functions is stated once up front rather than only in code comments.Removed duplicated prose. The overview blurb, the “what tidylearn is / is NOT” bullets and the principles list each existed verbatim in two or three of README,
getting-startedandPACKAGE_ARCHITECTURE.md;PACKAGE_ARCHITECTURE.mdnow links to the README for all three, the way it already did for the method-to-package table.getting-startedandsupervised-learningno longer close with a summary restating their own introductions.New vignette
compute-backends: howcompute = "auto"routes a fit, what the advisor estimates a cloud tier would cost, and the safety model that governs data egress.New vignette
market-basket: the association rules family (tidy_apriori(),inspect_rules(),filter_rules_by_item(),find_related_items(),recommend_products(),summarize_rules(),visualize_rules()) had no narrative documentation.New vignette
tuning-and-pipelines:tl_tune_grid(),tl_tune_random(),tl_default_param_grid(),tl_plot_tuning_results()and thetl_pipeline()family, none of which were covered.New vignette
diagnostics:tl_check_assumptions(),tl_influence_measures(),tl_detect_outliers(),tl_diagnostic_dashboard(),tl_compare_cv(),tl_test_model_difference(),tl_test_interactions(),tl_interaction_effects()andtl_explore().unsupervised-learningrewritten to use the package’s owntidy_*()andaugment_*()interface. It previously reached intomodel$fit$clusters,$fit$centers,$fit$loadingsand$fit$variance_explainedthroughout, and hand-rolled an elbow search, whileoptimal_clusters(),plot_elbow(),plot_silhouette(),suggest_eps()andexplore_dbscan_params()went unmentioned.automlnow executes. Twenty-three of its twenty-five chunks wereeval = FALSE, with hand-written#>lines that read as console output and were not. The budget-tier table of predicted model counts is replaced by a sweep that measures them.integration-workflowsno longer emits 135 recycling warnings from the PCA-then-cluster workflow, and its reported accuracy is no longer computed from mis-assigned clusters.supervised-learningseeds the missing-values example, which was unreproducible across builds.README links the documentation site and every article;
inst/CITATIONreports the installed version and year rather than a hard-coded 2025.inst/security/threat-model.mdis rewritten for the architecture the transport spike settled on: plain HTTPS to a Modal Web Function backed by an R worker, rather than reticulate driving the Python SDK. T1 and T4 named constraints that no longer apply, and no threat covered a user-supplied endpoint URL.
Internal
Removed four internal helpers with no callers:
create_obs_ids(),extract_response(),get_numeric_cols()andvalidate_data(). They had survived two reviews on the grounds that they looked like intentional utilities..github/workflows/pkgdown.yamlbuilds on pull requests without deploying, so a dangling article name fails a PR check rather than the first push to main, and deploys withclean: trueso removed pages leave the live site.
tidylearn 0.4.0
CRAN release: 2026-08-03
New Features
Compute backends (foundation)
tl_check_gpu()— detects local NVIDIA CUDA support and reports which GPU-capable backends (xgboost, keras, tensorflow, torch) are installed. Cheap detection: parsesnvidia-smioutput and checks installed packages without loading Python or fitting a model. Returns atidylearn_gpu_checkobject with aprint()method.tl_compute_advisor()— S3 generic that estimates runtime, peak RAM, and cost across local CPU, local GPU, and cloud GPU tiers for a given tidylearn method and dataset. Dispatches on either a method name (character) or a fittedtidylearn_supervisedmodel. Returns a structured recommendation with aprint()method. Cloud-tier estimates are reported but not yet executable; Modal integration will follow in a later iteration.
Compute backends (local GPU routing)
tl_model()now accepts acomputeargument on both supervised and unsupervised paths:"cpu"(default — existing behaviour),"gpu"(route to local CUDA when the method supports it),"auto"(consulttl_compute_advisor()and pick per call), or"cloud"(reserved; errors with a clear message until the Modal integration lands).tl_fit_xgboost(compute = "gpu")passesdevice = "cuda"toxgb.train(). Requires xgboost compiled with CUDA support.tl_fit_deep(compute = "gpu")defers to TensorFlow’s automatic CUDA detection — the argument is accepted for API consistency but does not itself change the keras model setup.All compute validation flows through
tl_resolve_compute()so the behaviour is uniform across paradigms: methods without an upstream GPU path (linear, glm, randomForest, pca, kmeans, etc.) warn and fall back to CPU when"gpu"is requested;"cloud"errors the same way on supervised and unsupervised methods. The resolved tier is recorded onmodel$spec$computefor both paradigms.
Compute backends (cloud reframed as memory-headroom tier)
-
tl_compute_advisor()now treats cloud as a “doesn’t fit on my machine” tier rather than a GPU-acceleration-only tier. Cloud estimates are produced for every method the advisor supports (not just GPU-eligible ones), and the recommendation flips to"cloud"whenever the local job is RAM-infeasible — including CPU-only methods like linear regression, SVM or random forest on very large data.Scope: the advisor covers the 13 supervised methods in
.tl_method_profiles. Unsupervised methods (PCA, k-means, MDS, clustering) are not modelled and calling the advisor on one errors. Reaching the cloud recommendation throughtl_model(compute = "auto")additionally requires a method with an upstream GPU path (xgboost,deep), sincetl_resolve_compute()short-circuits CPU-only methods to"cpu"before consulting the advisor. Calltl_compute_advisor()directly to get memory-headroom advice for the other supervised methods. New internal Modal instance tier table (
.tl_modal_tiers) listing CPU-RAM tiers (cpu-small,cpu-large,cpu-xlarge) alongside GPU tiers (t4,a10g,a100-40gb,a100-80gb). The advisor picks the cheapest viable tier for the workload based on RAM headroom and whether the method has an upstream GPU path. Pricing is approximate as of early 2026 and may drift; revise if Modal pricing changes.The advisor’s recommendation is no longer gated on
cloud$configured. The advisor advises optimally; the caller (tl_resolve_compute()) decides whether it can act on a cloud recommendation. Whencompute = "auto"and the advisor recommends cloud,tl_resolve_compute()emits a clear message that cloud isn’t yet wired up and falls back to local CPU.Print method updated: the cloud line now shows the chosen tier label (e.g.,
T4 (16 GB VRAM / 16 GB RAM)) alongside the time and cost estimate.
Compute backends (security threat model)
- Added
inst/security/threat-model.md— the contract for what cloud compute in tidylearn will and will not do once the Modal integration lands. Covers token handling (never read in R), data egress consent (per-callconfirm_upload = TRUEplus session-leveltl_cloud_consent()), ephemeral compute (no persistent Modal volumes by default), no telemetry, and an audit checklist that reviewers can grep / verify against the Modal-integration PR. The doc is shipped with the package so users (and CRAN reviewers) can find it viasystem.file("security/threat-model.md", package = "tidylearn").
Bug Fixes
These four defects produced plausible but wrong numbers rather than errors, so results computed with earlier versions should be rechecked.
tl_evaluate()scored classification models against raw prediction output rather than class labels. Because the defaultpredict()type returns probabilities for logistic regression, comparing them to factor labels gave an accuracy of exactly 0 for every logistic model. Evaluation now requeststype = "class"explicitly. Everything built ontl_evaluate()was affected —tl_cv(),tl_tune_grid(),tl_tune_random(),tl_run_pipeline(),tl_auto_ml()andtl_compare_cv()all ranked logistic models last regardless of how they actually performed.-
tl_evaluate()had nometricsargument, so a requested metric silently landed in...and was forwarded topredict(). Only accuracy (classification) or rmse/mae/rsq (regression) were ever returned.tl_evaluate()now takesmetricsand computes the requested set, delegating totl_calc_classification_metrics()for classification. Classification supports accuracy, precision, recall, sensitivity, specificity, f1, auc and pr_auc; regression supports rmse, mse, mae, mape and rsq.tl_cv()gains a matchingmetricsargument. This removes the “Could not determine best model … all values NA” warning from default pipeline runs and thereplacement has length zeroerror fromtl_tune_grid(metric = "f1").Regression
rsqis now1 - SS_res/SS_totrather than the squared correlation. The two agree for in-sample OLS; the squared correlation was optimistic on held-out data. tl_predict_pipeline()derived its centre and scale fromresults$processed_data, which is stored after standardization — so new data was rescaled against a mean of ~0 and an sd of ~1 and reached the model in raw units. Onmtcarswithmpg ~ wt + hpthis returned predictions near -230 for rows whose actual mpg was 21. The same defect made imputation substitute a standardized median (~0) for missing values instead of the raw-scale one.tl_run_pipeline()now records the medians, modes, centres and scales it learned inresults$preprocessing_stats, andtl_predict_pipeline()applies those. Pipelines run by an earlier version carry no such statistics and now raise a clear error asking for a re-run rather than silently producing wrong predictions. Constant columns are centred without dividing by zero.tl_auto_ml()’s leaderboard scores were alwaysNA.create_leaderboard()expected a result shape that neithertl_cv()nortl_evaluate()produces, so every model scoredNAand the reported “best model” was whichever trained first. Score extraction now handles both shapes, and the target metric is passed through to every evaluation.-
predict()on unsupervised models usednrow(new_data) == nrow(object$data)to decide whether new data had been supplied. Any new data with the same number of rows as the training set silently got the training result back — verified with a PCA projection of an all-999 frame returning the training scores.predict()now tracks whether the caller suppliednew_datarather than inferring it from row count. This also affectedpredict.tidylearn_transfer()andpredict.tidylearn_stratified(), which delegate to it.Methods with no out-of-sample projection (PAM, CLARA, MDS, DBSCAN, hierarchical clustering) now error when handed new data instead of returning training assignments that look like predictions. PAM and CLARA gained the training-data branch they previously lacked, and hierarchical clustering — whose fit holds a tree, not assignments — points at
tidy_cutree()rather than returningNULL. Prediction for
ridge,lassoandelastic_netbuilt its design matrix from a~ predictors - 1formula while the fit usedmodel.matrix()with the intercept dropped. The two disagree whenever a factor predictor is present: the fit uses treatment contrasts (k-1 columns), prediction one-hot encodes (k columns), so any such model failed withThe number of variables in newx must be N. The fit now records its terms and factor levels, and prediction rebuilds an identically-coded design matrix from them.Regularized classification ignored the
typeargument and always returned class labels, sotype = "prob"gave labels and ROC, calibration, lift and gain plots could not work for these models.type = "prob"now returns one probability column per class (binary and multinomial), andtype = "class"/"response"returns a factor carrying the training levels rather than a character vector. An unrecognised type errors instead of silently returning labels.method = "boost"could not fit a classification model at all:gbm()was handed a factor response withdistribution = "bernoulli", which requires a numeric 0/1 response. The response is now encoded with the second factor level as the positive class, matching the orientationtl_predict_boost()already assumed.plot()failed for every unsupervised method. Thetl_fit_*wrappers unpack thetidy_*objects into plain lists, but the plot helpers were handed the unpacked list: k-means, PAM, CLARA and DBSCAN partial-matched$clusterto the$clusterstibble and built a nested column; PCA and MDS hittidy_pca/tidy_mdsclass checks that a plain list cannot satisfy; hclust passed a list where anhclustobject was expected. Each method now supplies the structure its plot helper expects.
Compute backends (corrections)
parallelis now declared in Imports.tl_estimate_local_cpu_internal()callsparallel::detectCores(), which without the declaration produces an “‘::’ call not declared from” NOTE underR CMD check.testthatminimum raised to 3.1.7. The compute tests uselocal_mocked_bindings()(3.1.7) andexpect_no_warning()(3.1.5); on an older testthat the suite errored rather than skipped.tl_detect_cuda_internal()now checks the exit status ofnvidia-smi. A machine with the binary installed but the driver unloaded prints its error message to stdout and exits non-zero — that text was being parsed as a device name, sotl_check_gpu()reported a working GPU andcompute = "gpu"routeddevice = "cuda"into a fit that then failed.GPU routing for xgboost now requires xgboost >= 2.0.0, checked during backend detection. The
deviceparameter arrived in 2.0.0; older versions ignore unknown parameters, so the fit ran on CPU whilespec$computerecorded"gpu". Older versions are now reported as having no GPU path, socompute = "gpu"warns and falls back honestly.tl_model(compute = "auto")now forwards the caller’s runtime-relevant hyperparameters to the advisor. Previously the advisor always estimated a default-sized job, sotl_model(..., method = "xgboost", nrounds = 5000, compute = "auto")was costed asnrounds = 100and could choose CPU when GPU was the right call.tl_compute_advisor()no longer skips a local GPU that finishes quickly. The guard required an estimated GPU runtime of at least 5 seconds on top of a 3x speedup, so a job estimated at 70s on CPU and 4.7s on GPU — a 15x speedup — was reported as “No meaningfully faster tier available”. The sub-60s check earlier in the same function already covers jobs too small to bother offloading.tl_compute_advisor(fitted_model, formula = ...)no longer errors with “formal argument ‘formula’ matched by multiple actual arguments”. The documentation saysformulais ignored for a fitted model; now it actually is.
Other Changes
tl_auto_ml()now cross-validates the PCA-augmented and cluster-augmented variants when the budget allows. Previously these were scored on training data while baselines were cross-validated, so once scoring worked at all, overfit variants would have outranked honestly-scored models. The leaderboard gains anevaluationcolumn recording"cv"or"train"per model, since mixed scores are not directly comparable.tl_auto_ml()no longer fits logistic regression to a multiclass response — the implementation is binary-only, and the resulting model was meaningless. It errors early when the response has fewer than two observed classes.tl_run_pipeline()rejects an unnamedmodelsargument. Passing a character vector previously trained nothing and failed later with an indexing error.tl_evaluate()errors when the response column is absent fromnew_datainstead of computing metrics againstNULL.tl_tune_grid()andtl_tune_random()failed with “argument is of length zero” whenever ametricwas named without also namingmaximize. The optimisation direction was only assigned inside the branch that supplies a default metric, so an explicit metric leftmaximizeatNULLand the laterif (maximize)errored. Direction now follows the metric itself:rmse,mse,maeandmapeare minimised, everything else maximised. An explicitly suppliedmaximizeis still respected.Tuning a single hyperparameter dropped its name. Indexing one column of the results without
drop = FALSEcollapsed the row to a bare value, so the winning setting was passed totl_model()positionally and never reached the underlying fit — a tunedcporlambdawas silently discarded. Affected bothtl_tune_grid()andtl_tune_random().tl_plot_tuning_results(plot_type = "importance")errored on categorical parameters with “Can’t subset.dataoutside of a data mask context”. The ANOVA branch built its formula with the tidy-eval.datapronoun, whichaov()cannot evaluate; it now usesstats::reformulate().tl_plot_tuning_results(plot_type = "grid")errored with “object ‘p’ not found” when a parameter had more than 20 unique values. The fallback to a scatter plot called the function recursively but discarded the result.
Tests
New
test-metrics.Randtest-pipeline.Rcover the four fixes above;tl_evaluate()and the whole pipeline family previously had no test coverage, which is why the defects survived. Added leaderboard scoring and ranking tests totest-workflows.R.tl_auto_ml handles small datasetsusediris[1:30, ], which is entirely setosa. It passed only because a degenerate single-class logistic model was counted as a trained model. It now samples across all three species, and a separate test covers the single-class rejection.New
test-supervised-predict.Randtest-unsupervised-predict.Rcover the prediction fixes above, andtests/testthat/setup.Rdraws base-graphics test plots to a null device so they no longer leave anRplots.pdfbehind.
Documentation
Corrected vignette examples that printed wrong results. The integration-workflows vignette reported 0% accuracy in five places — it compared logistic regression’s probability output against factor labels, on a three-class response that logistic regression cannot represent. The supervised-learning vignette reported 33.3% (chance) for its complete-workflow example, which fitted on standardized features and then predicted on raw test data. Both now use multiclass-capable methods, score through
tl_evaluate(), and apply the training preprocessing to the test set.The getting-started and supervised-learning vignettes now explain that
predict()’s defaulttype = "response"returns probabilities for logistic regression but class labels for trees and forests, and showtype = "class"andtype = "prob"alongsidetl_evaluate().Re-enabled seven vignette chunks that were disabled while the underlying bugs were present: ridge, lasso, elastic net and SVM in the supervised-learning vignette, and PAM, DBSCAN and CLARA in the unsupervised-learning vignette.
Added package-level documentation, so
?tidylearnnow resolves.README: fixed a
predict()example that referenced columns which do not exist, replaced aplot_clusters()call that passed a model where a data frame is required, and added a section on the compute backends.tl_run_pipeline()documents the$preprocessing_statscomponent, andpredict()no longer advertises unsupervisedtypevalues that it ignores — its@returnnow describes the shape unsupervised models actually produce, and which of them acceptnew_data.tl_check_gpu()andtl_compute_advisor()examples now run rather than sitting in\dontrun{}; neither requires a GPU.
tidylearn 0.3.1
CRAN release: 2026-05-19
Performance
-
tidy_gower()— eliminated two layers of redundant work in the pairwise distance loop:- Column ranges (
max - min) and ordinal rank vectors were previously recomputed on every(i, j)pair. They are now computed once in a pre-pass, reducing work from O(n² × p) to O(n² + p). - Replaced scalar data-frame indexing
data[i, k]— which dispatches to the R-level[.data.framemethod on every call — with pre-extracted plain-vector accesscol_vecs[[k]][i], which resolves at the C level. Benchmarks show 10–100× faster scalar access; the gain compounds across the fulln*(n-1)/2 * piterations. - Column types (
is.numeric,is.ordered) are now resolved once into acol_typecharacter vector, removing repeated S3 predicate calls from the inner loop.
- Column ranges (
Bug Fixes
- Fixed
tl_reduce_dimensions()returning the internal.obs_idrow identifier as a column of its$dataresult. Passing that data to a supervised model via aresponse ~ .formula fed.obs_idin as a high-cardinality predictor, which made tree-based fits effectively non-terminating. The identifier is now dropped from the returned data, consistent with how the pipeline and transfer-learning paths already handle it. - Fixed
print()andsummary()erroring on the model objects returned bytl_step_selection()andtl_tune_xgboost(). Both constructed their object without thespec$paradigmfield or thetidylearn_supervisedclass, so the print method hit a zero-lengthifcondition andsummary()took the unsupervised branch. Both objects are now built consistently withtl_model(). - Fixed
tidy_gower()(andtidy_dist(..., method = "gower")) erroring on single-row input. The pairwise loop used1:(n - 1), which produces the invalid sequence1:0whennis 1; it now usesseq_len(n - 1), so a single-row data frame returns an emptydistobject, consistent withstats::dist().
Tests
- Added 11 tests for
tidy_gower()/tidy_dist(..., method = "gower")covering: return type and metadata, symmetry and self-distance, identical rows, hand-verified numeric / categorical / ordered / mixed-type distances, NA skipping, custom weights, constant-column denominator behaviour, and single-row input.
tidylearn 0.3.0
CRAN release: 2026-04-09
New Features
Data Ingestion (tl_read() Family)
- New
tl_read()dispatcher function — auto-detects format from file extension, URL pattern, or connection string and routes to the appropriate reader - All readers return a
tidylearn_dataobject, a tibble subclass carrying source, format, and timestamp metadata viaprint.tidylearn_data()
File Format Readers
-
tl_read_csv()/tl_read_tsv()— via readr with base R fallback -
tl_read_excel()—.xls,.xlsx,.xlsmfiles via readxl -
tl_read_parquet()— via nanoparquet -
tl_read_json()— tabular JSON via jsonlite -
tl_read_rds()/tl_read_rdata()— native R formats via base R
Database Readers
-
tl_read_db()— query any live DBI connection -
tl_read_sqlite()— auto-connect to SQLite files via RSQLite -
tl_read_postgres()— connection string or named params via RPostgres -
tl_read_mysql()— connection string or named params via RMariaDB -
tl_read_bigquery()— Google BigQuery via bigrquery
Cloud/API Readers
-
tl_read_s3()— download and read from S3 URIs via paws.storage -
tl_read_github()— download raw files from GitHub repositories -
tl_read_kaggle()— download datasets via the Kaggle CLI
Multi-File Reading
-
tl_read()accepts a character vector of paths — reads each and row-binds with asource_filecolumn -
tl_read_dir()— scan a directory for data files with optional format, pattern, and recursive filtering -
tl_read_zip()— extract and read from zip archives, with optional file selection - All backend packages are suggested dependencies, checked at call time via
tl_check_packages()
New Vignette
- Added “Data Ingestion with tidylearn” vignette covering all readers, databases, cloud sources, multi-file reading, and the full pipeline
- Updated “Getting Started” vignette to include
tl_read()in the workflow
Bug Fixes
Workflow and Pipeline Fixes
- Fixed
tl_transfer_learning()hanging indefinitely when used with PCA pre-training. The.obs_idrow-identifier column from PCA output was being included in the supervised formula, creating a massive dummy-variable matrix. The column is now stripped before both training and prediction. - Fixed
tl_run_pipeline()failing with “attempt to select less than one element” when all cross-validation metrics were NA. Root cause:scale()returned matrix columns instead of vectors, causing downstream metric computation to produce NaN. Addedas.vector()wrapper and hardened the best-model selection to handle all-NA metric values gracefully. - Overhauled
tl_auto_ml()time budget enforcement. The budget now controls which models are attempted: budgets under 30s skip slow C-level models (forest, SVM, XGBoost) entirely, and cross-validation is skipped when remaining time is tight. Baseline model order changed to fast-first (tree, logistic/linear, then forest). See?tl_auto_mlfor full details on budget tiers.
Interaction and Prediction Fixes
- Fixed
tl_interaction_effects()crashing with “unused argument (se.fit)” because tidylearn’spredict()method does not supportse.fit. Now usesstats::predict()on the raw model object for confidence intervals. Also fixed an invalid formula in the internal slope calculation. - Fixed
tl_plot_interaction()expectingfit/lwr/uprcolumns frompredict()output. Now correctly handles tidylearn’s.predtibble format.
Visualization Fixes
- Fixed
tl_plot_intervals()calling non-existenttl_prediction_intervals()function. Now computes confidence and prediction intervals directly viastats::predict(..., interval = "confidence")andstats::predict(..., interval = "prediction"). - Fixed
tl_plot_svm_boundary()erroring with “at least two predictor variables required” when usingresponse ~ .formulas. The function now resolves predictors from data column names instead ofall.vars(), which does not expand.. Also switched fromgeom_contour_filled(which failed on discrete class predictions) togeom_raster. - Fixed
tl_plot_svm_tuning()passingNULLentries in therangeslist toe1071::tune(), which caused “NA/NaN/Inf in foreign function call” errors. Tuning ranges are now built conditionally based on the kernel type. - Fixed
tl_plot_xgboost_shap_summary()failing with “arguments imply differing number of rows” whenn_samplesdiffered fromnrow(data). Sampling is now performed before SHAP computation so that feature values and SHAP values always have the same number of rows.
Other Fixes
- Fixed classification auto-detection silently treating numeric responses with <= 10 unique values as classification. The response must now be a factor or character for classification; a helpful message is emitted when a low-cardinality numeric response is detected.
- Fixed
tl_check_assumptions()crashing with “list object cannot be coerced to logical” when some assumption checks returned NULL (e.g., when optional test packages were not installed). - Fixed SVM default
gammacalculation to use predictor count only (1 / (ncol(data) - 1)) instead of including the response column. - Added missing
@returntag toprint.tidylearn_data(). - Replaced deprecated ggplot2
sizeparameter withlinewidthin allgeom_line()calls across visualization, classification, PCA, DBSCAN, and validation plotting functions.
Tests
- Added test suite for visualization module (26 tests) — plot dispatch, regression/classification plots, lift/gain charts, model comparison, unsupervised visualization, and Shiny dashboard.
- Added test suite for tuning module (49 tests) —
tl_default_param_grid,tl_tune_grid,tl_tune_random,tl_plot_tuning_results, and input validation. - Added test suite for diagnostics module (75 tests) — influence measures, influence plots, assumption checking, and outlier detection across all methods (IQR, z-score, Cook’s, Mahalanobis).
Code Quality
- Package-wide lint cleanup — all R source files, tests, and vignettes now pass lintr with zero issues
- Replaced unsafe
1:npatterns withseq_len()/seq_along() - Removed unused variables across the codebase
- Renamed non-snake_case variables to follow R conventions
- Added
.lintrconfiguration enforcing%>%pipe consistency
tidylearn 0.2.0
CRAN release: 2026-03-16
New Features
Formatted gt Tables
- New
tl_table()dispatcher function — mirrorsplot()but produces formattedgttables instead of ggplot2 visualisations -
tl_table_metrics()— styled evaluation metrics table fromtl_evaluate() -
tl_table_coefficients()— model coefficients with p-values (lm/glm) or sorted by magnitude (glmnet), with conditional highlighting -
tl_table_confusion()— confusion matrix with correct predictions highlighted on the diagonal -
tl_table_importance()— ranked feature importance with colour gradient -
tl_table_variance()— PCA variance explained with cumulative % coloured -
tl_table_loadings()— PCA loadings with diverging red–blue colour scale -
tl_table_clusters()— cluster sizes and mean feature values for kmeans, pam, clara, dbscan, and hclust models -
tl_table_comparison()— side-by-side multi-model comparison table - All table functions share a consistent
gttheme via internaltl_gt_theme()helper -
gtis a suggested dependency — functions error with an install message ifgtis not available
Bug Fixes
- Fixed
tl_fit_dbscan()returning a non-existentcore_pointsfield instead ofsummaryfrom the underlyingtidy_dbscan()result
tidylearn 0.1.1
CRAN release: 2026-03-13
Bug Fixes
- Fixed
plot()failing on supervised models with “could not find function ‘tl_plot_model’” by implementing the missingtl_plot_model()andtl_plot_unsupervised()internal dispatchers (#1) - Fixed
tl_plot_actual_predicted(),tl_plot_residuals(), andtl_plot_confusion()failing due to accessing a non-existent$predictioncolumn on predict output (correct column is$.pred) - Fixed the same
$predictioncolumn mismatch in thetl_dashboard()predictions table
tidylearn 0.1.0
CRAN release: 2026-02-06
Initial CRAN Release
- First release of tidylearn - a unified tidy interface to R’s machine learning ecosystem
Features
Unified Interface
-
tl_model()- Single function to fit 20+ machine learning models - Consistent function signatures across all methods
- Tidy tibble output for all results
- Access raw model objects via
$fitfor package-specific functionality
Supervised Learning Methods
- Linear regression (stats::lm)
- Polynomial regression (stats::lm with poly)
- Logistic regression (stats::glm)
- Ridge, LASSO, elastic net (glmnet)
- Decision trees (rpart)
- Random forests (randomForest)
- Gradient boosting (gbm)
- XGBoost (xgboost)
- Support vector machines (e1071)
- Neural networks (nnet)
- Deep learning (keras, optional)
Unsupervised Learning Methods
- Principal Component Analysis (stats::prcomp)
- Multidimensional Scaling (stats, MASS, smacof)
- K-means clustering (stats::kmeans)
- PAM clustering (cluster::pam)
- CLARA clustering (cluster::clara)
- Hierarchical clustering (stats::hclust)
- DBSCAN (dbscan)
Additional Features
-
tl_split()- Train/test splitting with stratification support -
tl_prepare_data()- Data preprocessing (scaling, imputation, encoding) -
tl_evaluate()- Model evaluation with multiple metrics -
tl_auto_ml()- Automated machine learning -
tl_tune()- Hyperparameter tuning with grid and random search - Unified ggplot2-based visualization functions
- Integration workflows combining supervised and unsupervised learning
