tidylearn 0.6.0
CRAN release: 2026-10-08
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.tidylearn requires ggplot2 3.4.0 or later. The plots set line widths with the
linewidthaesthetic, which ggplot2 3.3 ignores with an unknown-parameter warning and draws at the default width. The rlang floor rises from 0.4.0 to 1.0.0, the version ggplot2 3.4.0 imports, so it changes nothing for an install that meets the ggplot2 one.-
Some calls that ran in 0.5.0 are now errors, with a message naming the problem: each passed an argument tidylearn ignored or returned a meaningless result. Bug Fixes describes all of them. Among them are:
- an argument a method cannot use, such as
maxdeth = 1oroffset =withmethod = "tree", a name glmnet does not take with"ridge","lasso"or"elastic_net", and a misspelt argument totl_pipeline(); - an
offset()term with a method that cannot apply it atpredict(), which is every method but"linear","polynomial"and"logistic"; - a metric name
tl_evaluate()ortl_cv()does not compute, such as"RMSE"; -
predict(type = "prob")ortype = "class"on a regression model, and a misspelttype; -
tl_prepare_data(impute_method = "knn"), andtl_split()with apropoutside (0, 1); - repeated model names in
tl_compare_cv()andtl_plot_importance_comparison(); -
typeintl_plot_interaction(), and anexclude_varsintl_auto_interactions()that is not a predictor; - a numeric response in
tl_semisupervised(), andtl_anomaly_aware(action = "downweight")with"svm"or"deep".
- an argument a method cannot use, such as
tl_model()makes a text predictor a factor before fitting, and stores it as one in$data.method = "forest"coded a character column as numbers by the values present in whichever frame it was handed, so a row’s prediction depended on the rows scored with it: on a 120-row frame drawn afterset.seed(7), in which group"c"sits near 10, three"c"rows scored alone predicted -0.11, -0.15 and 0.45, and 9.47, 9.26 and 9.78 inside the full frame. A forest now splits on the column as a category, so its fitted values change, and a text column with more than 53 distinct values is refused by randomForest (“Can not handle categorical predictors with more than 53 categories”); exclude identifiers withy ~ . - id.method = "boost"fits a text predictor, where gbm refused it (“variable 1: grp is not of type numeric, ordered, or factor”), and"xgboost"predicts a single row of one, where it failed with “contrasts can be applied only to factors with 2 or more levels”. A text column used only inside a term, such asas.numeric(code), keeps its type.predict()reads a supervised model’s categorical columns against the levels it was trained on, before any method sees them. New data holding only some of the categories works with"forest", which failed with “Type of predictors in new data do not match that of the training data” fordata.frame(cyl = factor("6"), wt = 3). A value given as text, or as the number a factor was made from, reads the same. A level the model was not trained on is an error naming the column, the level and the training levels;"boost"used to return a prediction for it, and the other methods’ errors came from their backends.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.A
"ridge","lasso"or"elastic_net"model is now the full-data fitcv.glmnet()makes itself, in place of a secondglmnet()run along the same penalties. The two agreed only to glmnet’s convergence tolerance, so coefficients and predictions move slightly – by up to 0.004 atlambda_1seformpg ~ .onmtcarswithset.seed(1)– and now matchcoef()andpredict()on thecv.glmnetobject kept inattr(model$fit, "cv_results"). When cross-validation chooses the largest penalty, every slope is now exactly zero: the second fit left one at about -6e-17, whichtl_table_importance()ranked at importance 100 andtl_plot_importance_regularized()drew as a bar of 5.9e-17 (predictorbof 40 rows of noise drawn afterset.seed(3)). Both now stop with the message that the penalty dropped every predictor, as for any penalty that keeps none.method = "polynomial"keeps a numeric term that is also part of an interaction as it is and adds its powers asI(x^2)up to the degree. Formpg ~ wt * hpthe fit and its predictions are those of 0.5.0, but the terms are nowwt,hp,I(wt^2),I(hp^2)andwt:hp, where 0.5.0 named thempoly(wt, degree = 2, raw = TRUE)1,poly(wt, degree = 2, raw = TRUE)2and so on, sotl_coefficients()andtl_table_coefficients()list other names in another order.predict()on an unsupervised model returns.obs_id, the row names of the data predicted on, and as many components as the model keeps, on both the training and the new-data path. A two-dimensional MDS fit fromtl_reduce_dimensions(n_components = 2)predicted.obs_idandDim1only, andDim1andDim2without.obs_idfor tibble input. PCA’s training projection returned every component however many were kept. k-means assignments of new data had no.obs_id, and PCA projections of new data numbered the rows"1","2"whatever they were called.predict(type = "prob")on an"xgboost"model returns a tibble, as every other method does. It returned a data frame.tl_split(stratify = )cuts a numeric column with more than five distinct values at its quartiles. Each distinct value used to be a stratum, and a stratum holding a single row corrupted the split: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. On 50 rows of a continuousywithseed = 1, 0.5.0 drew 50 training rows holding only 22 distinct observations and put 28 in test; the same call now splits 38/12, each quartile atprop. Onmtcarsstratified bympg, most of whose values occur once, the split withseed = 1returned 43 rows from 32, and now splits 24/8. A numeric column with five or fewer distinct values, such ascyl, is still split by value. An integer ID such as1:50has more than five, so it is cut at its quartiles like any other numeric column. A stratum of a single row goes to the training set, so a level seen once is one the model was trained on. When such strata together hold more than a tenth of the rows, as in a character ID column, they are pooled into one stratum and split together; for a column of 50 distinct strings that is the unstratified draw, 40/10. A split that leaves the test set empty, which only a single row now does, warns.tl_model(method = "deep")holds out a random set of rows forvalidation_split, recorded in$fit$validation_rows. keras holds out the last rows as given, which oniris, sorted by species, were rows 121 to 150: 30 of the 50 virginica rows, so the model trained on 20 virginica against 50 of each other class and was validated on virginica alone. A row with a missing value is dropped from the predictors and the response together; the response kept it, so keras trained on pairs shifted by one row. A constant predictor is no longer scaled by its standard deviation of 0, which turned it intoNaNand gave every row the same prediction.tl_tune_xgboost(),tl_tune_nn()andtl_tune_deep()read the task from the response, astl_model()does.is_classificationdefaults toNULL, which treats a factor or character response, including one the formula computes such asfactor(am), as classification. With the old default ofFALSE,tl_tune_xgboost(iris, Species ~ .)was tuned as a regression on the class codes without a message, andtl_tune_nn()andtl_tune_deep()failed.is_classification = FALSEwith a factor response is an error, and so isis_classification = TRUEwith a computed response that is not a factor, such asI(mpg > 20), whichtl_tune_nn()ran; the message points tofactor()on the left-hand side. The response is reduced to the classes it holds, so a two-class subset ofiristhat still declares setosa is tuned as two classes;tl_tune_xgboost()had tuned it withmulti:softproband a setosa probability column.tl_tune_deep()returns the best configuration as atidylearn_modelin$model, sopredict()and the deep plots take it; the keras model is at$model$fit$model. It returned the internal list, andpredict()on it failed withno applicable method. Every configuration is scored on the same randomly held-out rows.tl_default_param_grid("xgboost")returns a grid: the valuestl_tune_xgboost()searches by default, plusnrounds, which that function picks by early stopping andtl_tune_grid()has to tune. It warned “Unknown method” and returned an empty list.is_classification, which changed no grid, now changes two regression grids:"svm"also tunesepsilon, and the large"forest"grid usesnodesize = c(3, 5, 10)around randomForest’s regression default of 5 in place ofc(1, 3, 5). With the defaultis_classification = TRUEthe grids are unchanged.tl_plot_xgboost_importance()returns a ggplot, as documented, and ranks by the measureimportance_typenames. It returnedxgb.plot.importance()’s data.table, drew with base graphics, and ignoredimportance_type. A linear booster (booster = "gblinear") has only coefficients, whichimportance_type = "weight"draws, ranked by size; left at its default,importance_typetakes them for such a model.tl_plot_partial_dependence()on a classification model draws one line per class when there are three or more, and the positive class (the second level) for two, named on the y axis. Its data has aclasscolumn for the class each row describes. The curve was the mean probability of the second class alone, and the multiclassclasscolumn held whichever class had the highest mean probability, which was not the class drawn....intl_semisupervised()andtl_stratified_models()reaches the supervised model only, and the newcluster_argstakes the clustering step’s settings. Both stages got..., socp = 0.001failed in k-means with “unused argument”, andnstart = 5reached k-means and then brokeglm(). A clustering setting now goes incluster_args, e.g.cluster_args = list(nstart = 5); one left in..., such asnstart,iter.max,algorithm,hclust_methodor pam’svariant, is refused with a message saying so.sampsize,keep.dataandtrace, which randomForest, gbm and nnet take, still reach the supervised model.tl_semisupervised()with a response the formula computes, such asfactor(am) ~ wt + hp + qsec, writes each propagated label to the column the response is computed from, as that column’s own value in a labelled row of the same class. The returned$data$amtherefore holds 0 and 1, where it held a factor.tl_interaction_effects()holds a variable it does not vary at its most frequent value, astl_plot_interaction()does. It held a factor at its first level: withcarba factor,mpg ~ wt * hp + carbwas evaluated atcarb = 1by one function andcarb = 2by the other.tl_interaction_effects()returns a factor variable in its results –var,by_varor a variable it holds fixed – as a factor with the model’s levels, where it was a character vector; the values are unchanged. That is what lets it work for a forest with a factor predictor, which failed withType of predictors in new data do not match that of the training databecause the grid held the factor as text. Both it andtl_plot_interaction()leave out a factor level no row uses: on iris without setosa, both predicted at setosa, whichpredict()refused as a new level. They also leave out a missing value of a character variable, whichtl_interaction_effects()failed on with0 (non-NA) cases. A logical variable is treated as two-level; both functions failed withvariable 'lg' was fitted with type "logical" but type "numeric" was supplied. A factor value given inat_valuesorfixed_valuestakes the column’s levels, and one that is not a level is refused.mpg ~ log(wt) * hpno longer warns that the model has nowt:hpinteraction.tl_interaction_effects()reportsfit,lower,upperandslopeon the response scale whether or notintervals = TRUE, and itssecolumn is removed. 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. For a glm thesecolumn 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.augment_hclust()attaches clusters by position. It joined onrow_number(), which counts within each group, so on a grouped tibble ofUSArrestscut atk = 334 of 50 rows received another row’s cluster. Data with a different number of rows from the tree was padded withNAor cut short without a message; it is now an error naming both counts. The caller’s grouping stays on the returned data, and so do its row names, which the join reset to"1","2", …; aclustercolumn the data already has is kept beside the new one under dplyr’s repaired names (cluster...5andcluster...6forUSArrests), where the join gavecluster.xandcluster.y.tidy_rules(), and sotidy_apriori()’srules_tbl, gains the list columnslhs_itemsandrhs_items, each side’s items as a character vector, after the quality measures. The item-matching helpers read them; a table of rules without them is refused with a message saying to keep them, and atidy_apriori()result saved without them is rebuilt from the rules it carries. An empty rule set now gives a zero-row tibble with the usual columns. It gave a tibble with no columns, sorecommend_products()failed withobject 'confidence' not found,filter_rules_by_item()andfind_related_items()withobject 'lhs' not found, andvisualize_rules()when the plot was drawn; they now return zero rows, or an empty plot.tidy_pam()’smedoid_indexis the medoid’s integer row position.pam()reports medoids by label when the distances carry labels, sotidy_pam(mtcars, k = 3)gave"Toyota Corona","Merc 450SE"and"Duster 360"where the same data as a tibble gave 21, 12 and 7. Both now give 21, 12 and 7.calc_validation_metrics()andcompare_clusterings()gain ann_noisecolumn, afteravg_size, counting the points labelled 0, DBSCAN’s label for noise. The other clustering methods number their clusters from 1, so it is 0 for them. The columns after it move one place along.Counts that ran a range backwards or reached
cutree()as a vector are refused with a message naming the argument.get_pca_loadings()andaugment_pca()withn_components = 0returned PC1, since1:0isc(1, 0), andget_pca_loadings(n_components = 5)on a four-component PCA returned all four;n_componentsmust now be between 1 and the number of components.tidy_cutree(k = 2:3)returned 100 rows for 50 observations, and now requires a singlekorh.compare_clusterings()returned a 0 x 0 tibble for an unnamed list, or for a single vector passed in place of a list, and failed on an unnamed entry of a named one; it names such entriesclustering_1,clustering_2, …, and refuses anything that is not a list.max_k = 1inoptimal_hclust_k(),tidy_silhouette_analysis()andtidy_gap_stat(), andmax_k = 0incalc_wss(), are refused by name, where the first two failed withincorrect number of dimensions,tidy_gap_stat()withK.max >= 2 is not TRUE, andcalc_wss()insidekmeans().The
source_filecolumn names each row’s file by its path below the directory or archive it came from, or, for paths given totl_read()directly, below the deepest folder they share. It held base names, sotl_read_dir(recursive = TRUE)labelled2023/sales.csvand2024/sales.csvbothsales.csv, losing a partition key held in a folder name. Files in one folder keep their bare names. When one file already has asource_filecolumn, every file’s label now goes totl_source_file; the choice was made per file, which put the other files’ labels in that file’s own column. Thetl_sourceattribute of a zip member read on its own names the member’s path within the archive too, asarchive.zip//2024/sales.csvwhere it heldarchive.zip//sales.csv.tl_read_dir(),tl_read()on a folder, andtl_read_zip()withoutfileread compressed CSV and TSV files and.ndjsonfiles along with the rest, so a folder or archive holding them next to other data now row-binds them too: a folder ofcars.csvandcars_backup.csv.gzreturned mtcars’s 32 rows and now returns 64.format = "csv"takes the compressed CSVs as well.patternrestores 0.5.0’s selection for a folder:pattern = "\\.(csv|tsv|xlsx?|xlsm|parquet|json|rds|rdata|rda)$"in place of the default scan, orpattern = "\\.csv$"in place offormat = "csv", matching the case of the extension where 0.5.0 ignored it. In an archive, name the member withfile. Scans still skip.txtfiles, whichtl_read()reads as CSV only when named directly.tl_read_kaggle()finds a requested file the Kaggle CLI saved under its base name, sofile = "data/train.csv"is read where it was reported missing. Withoutfile, the search for a data file uses the extensions its readers handle, from the same table astl_read_dir(): it knew six of its own, so a dataset of.ndjson,.xlsmor compressed CSV files had “no data files”, and where such files sit beside others the newest of all of them is now read.tl_read_zip(format = )selects the members of that format from an archive holding a single data file, as it already did for several. It forced the format onto a lone member, so a zip holding onlydata.jsonread withformat = "csv"returned 0 rows with fragments of the JSON as column names; it is now an error saying the archive holds no CSV. To force a format onto a member, name it withfile.On R before 4.4.0,
tl_read_github()andtl_read_s3()refuse.rds,.rdataand.rdafiles unless the newtrust_rds = TRUEis given. Those versions can run code embedded in a crafted file as it is read (CVE-2024-27322), and these readers handed downloaded bytes straight toreadRDS()andload(). R 4.4.0 and later read them as before. The help pages oftl_read_rds()andtl_read_rdata()say to read such files only from trusted sources.tl_check_gpu()no longer reportstorch, which is neither a tidylearn backend nor a suggested package, so itsbackendslist has three entries. It reads which backends are installed from the library:requireNamespace()loaded xgboost, tensorflow, keras and reticulate to learn they were there, and loading keras setTF_USE_LEGACY_KERAS = "1"for the rest of the session.nvidia-smiis given 10 seconds to answer, after which the check warns and reports no GPU; a hung driver held uptl_check_gpu(),tl_compute_advisor()and every xgboost or deep fit withcompute = "auto"or"gpu"for as long as it hung.tl_cloud_allow_host()refuses a host name of fewer than three labels.co.uk,github.ioandngrok.iowere accepted, after which any site registered under them, such asattacker.co.uk, passed as an upload destination. Without the Public Suffix List the rule is the label count, so it also refuses an apex domain such asexample.com, and a public suffix of three or more labels still passes. Give the endpoint’s full host name. Cloud submission is not wired up, so no upload could reach such a host.
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 — 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(). A call without them gives the table it gave before, apart from three changes described elsewhere in this section: regularised coefficients come from thecv.glmnet()fit, a term the fit could not estimate gets a row, and the source note counts the rows the fit used. 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).tl_tune_grid(),tl_tune_random()andtl_compare_cv()acceptfolds = nrow(data)and leave each row out in turn, astl_cv()does, and so doestl_run_pipeline()withevaluation$cv_foldsequal to the number of rows. rsample’svfold_cv()refused it with “Leave-one-out cross-validation is not supported by this function”.tl_read()andtl_read_json()read newline-delimited.ndjsonfiles, throughjsonlite::stream_in(), andtl_read_json()reads a.jsonfile holding one record per line (JSON Lines). Both failed with “parse error: trailing garbage”: the.ndjsonextension was mapped to JSON, and a JSON Lines file was read as a single document. A file that parses neither way is an error naming both. CSV and TSV files compressed with gzip, bzip2 or xz (data.csv.gz) are recognised by the extension under the compression one.tl_model(method = "mds")reaches every MDS variant.tl_model()’s ownmethodargument holds"mds", sotidy_mds()’s"metric","nonmetric","sammon"and"kruskal"could not be asked for, andndim = 3failed withformal argument "ndim" matched by multiple actual arguments. The variant is now chosen withmds_method(default"classical"), and the dimension count isndimork; giving both with different values is an error.tl_model(method = "hclust")takes the linkage ashclust_method(default"average"), astl_model(method = "mds")takesmds_method.tl_model()’s ownmethodargument holds"hclust", so the linkage could not be chosen and every tree used average linkage.tidy_mds()takesdistance = "gower", computed bytidy_gower()over every column, andtl_model(method = "mds", distance = "gower")uses the factor columns its formula names.distancewent straight tostats::dist(), which failed withinvalid distance method. Classical MDS on undefined distances, a pair of rows with no variable observed in both, now stops naming the rows and pointing tomethod = "metric"or"nonmetric", which leave them out;cmdscale()said onlyNA values not allowed in 'd'.tidy_apriori()no longer prints arules’ 21-line mining trace on every call. It gainscontrol, whoseverbosedefaults toFALSE; the rest of the list goes toarules::apriori()as given, andcontrol = list(verbose = TRUE)brings the trace back. Its new...reachesarules::apriori()too:appearance, and further mining parameters such assmaxormaxtime.tidy_clara()andtidy_pam()gain...for the arguments ofcluster::clara()andcluster::pam():tidy_clara(x, k = 3, correct.d = TRUE)wasunused argument, andtidy_pam()can now takenstart,variantor startingmedoids.cluster.only = TRUEfor both,dissfortidy_pam()andmedoids.x = FALSEfortidy_clara()are refused, under any abbreviation R accepts, since the result is built from the full fit and its medoids.
Bug Fixes
Fitting and prediction
tl_model()decides between classification and regression from the response the formula computes.factor(cyl) ~ wtwithmethod = "linear"was fitted bylm()on the factor codes, with only backend warnings, and is now refused as a factor with 3 classes; with"ridge"it was treated as regression and failed with “invalid to change the storage mode of a factor”, and with"forest"it returned a classification forest described as a regression. A response the formula computes as text, such asifelse(mpg > 20, "hi", "lo") ~ wt + hp, is classified as the factor it encodes by"nn","forest"and"svm", and bytl_tune_nn(): nnet stopped withNA/NaN/Inf in foreign function call (arg 2), randomForest withnon-numeric argument to binary operator, and e1071 withNeed numeric dependent variable for regression.A supervised method given a one-sided formula, or none, says it needs a two-sided formula naming the response. Given~ wt + hp,"forest"fitted an unsupervised forest,"xgboost"a booster with no objective, and"polynomial"a model ofwton itself andhp;"logistic"tookwtas the response and refused it, and the other methods failed inside their backends,"linear"with “incompatible dimensions”.predict()passes a method only the columns its formula uses. A column the model never saw counted as a predictor wherever a method expanded a.formula over the new data: an extra, mostly-missingnotescolumn turned 27 of 30"svm"predictionsNA, and an extra column gave"xgboost"a warning about columns not in the training data.predict()on a supervised model refuses new data that lacks a column the model’s predictors were fitted on: “New data is missing predictors used at fit time: hp”.model.frame()looks a variable up in the data and then in the formula’s environment, so every method took a missing column from a same-named object in the caller’s session, or failed with “object ‘hp’ not found” when there was none:predict(tl_model(mtcars, mpg ~ wt + hp, method = "linear"), nd)on data withouthpreturned predictions built from a globalhp. A variable the formula takes from its environment, such askinoffset(k * disp), is not required.tl_xgboost_shap(),tl_plot_xgboost_shap_summary()andtl_plot_xgboost_shap_dependence()checkdatathe same way, where they computed SHAP values from a globalhpor failed withobject 'hp' not found.predict(type = "prob")andtype = "class"on a regression model are errors. Both returned the numeric predictions, and a regression model ignored a misspelt type in the same way; an unrecognised type is now an error for every supervised model.predict()on zero rows returns zero rows with the columns and types of a non-empty prediction, where"logistic"failed with “eta must be a nonempty numeric vector”, the class probabilities of a multiclass"ridge","lasso"or"elastic_net"model with “non-conformable arrays”, and"xgboost"with an input pointer misalignment.A supervised model fitted on a formula that subtracts a column, such as
y ~ . - id, no longer needs that column atpredict().terms()keeps a subtracted column among its variables, so every method but"polynomial"and"boost"refused new data without it, most with “object ‘id’ not found” – the baselinestl_auto_ml()fits on such a formula among them. The model is now fitted on the formula written out without the column;$spec$formula,print()and every refit keep the formula as written, and a missing value in the subtracted column no longer leaves an"svm"predictionNA. A subtracted name that is neither a column of the data nor, for a supervised model, a variable in the formula’s environment – usually a misspelling, as inmpg ~ . - qsce– is an error that names it.tl_model(subset = )fits the selected rows, with every method, and stores only those in$data."linear","logistic"and"nn"failed onsubsetwith “..1 used in an incorrect context”,"boost"with “unused argument”, and"tree","ridge","lasso","elastic_net"and"xgboost"fitted every row,"xgboost"warning “Passed unrecognized parameters: subset”; a regularised fit withsubset = 1:16used all 32 rows."forest","svm"and"polynomial"fitted the selected rows but kept every row in$data, sopredict(model)andtl_evaluate(model)scored rows the fit never saw. The other per-row arguments, such asweights, are taken to the same rows.-
tl_model()passes caseweightstolm(),glm()andrpart(), to gbm for"boost", to nnet for"nn"and to the trainingxgb.DMatrix()for"xgboost", so weighted"tree"and"xgboost"fits change. For"linear","logistic","boost"and"nn"it failed with..1 used in an incorrect context, and"xgboost"warned that it did not recognise them and fitted without them."svm"and"deep"refuseweights: e1071 has no case weights, so a weighted svm fit was identical to the unweighted one while the model recorded that weights had been used, and keras’sfit()accepted them through its...and ignored them.class.weightsstill reaches e1071.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,maxdeth = 1fitted the default tree, andoffset = mtcars$hpfitted without an offset.rpart()’s own arguments now go torpart(), and an argument that neitherrpart()norrpart.control()takes is refused by name, asrpart()itself refuses one. An explicitminsplitgiven with acontrollist brings its derivedminbucket, unlessminbucketis passed too. tl_model()refuses an offset for"ridge","lasso","elastic_net","tree","forest","boost","svm","nn","deep"and"xgboost", whether as anoffset()term in the formula or as theoffsetargument, and names it. For the regularised methods anoffset()term was left out of the fit – the coefficients ofmpg ~ wt + hp + offset(disp / 100)were those of the fit with no offset – and anoffsetargument was fitted but leftpredict()failing withNo newoffset provided for prediction; their error points to"linear"or"logistic"withoffset()in the formula. None of the other seven applies an offset atpredict(): rpart and gbm fit anoffset()term as a shift of the response that their predictions never add back, and the other five leave it out of the fit. Held at the same predictors, each method’s prediction moved by 0 when the offset moved by 100. As an argument, an offset was ignored without a message by"tree","forest","svm","nn"and"deep";"xgboost"warned that xgboost did not recognise it, and"boost"stopped with gbm’sunused argument."linear"and"logistic"still take anoffset()term, and"polynomial", which dropped one, now applies it. Anoffsetargument is refused with a pointer tooffset()in the formula, which is the formpredict()can apply to new data.A classification model’s classes (
$spec$response_levels) are those of the rows it is fitted on. Every method but"tree","boost"and"xgboost"leaves out rows with a missing value, so a class whose every row has one is not in the fit; in"ridge","lasso"and"elastic_net"it stayed a level of the fitted response. With each setosaSepal.Widthinirismissing,"lasso"failed at fit with “one multinomial or binomial class has 1 or 0 observations; not allowed”, and"nn"failed atpredict()with “‘names’ attribute [3] must be the same length as the vector [2]”; both now fit and predict the two remaining classes, and the regularised methods fitSpecies ~ .as versicolor against virginica. A"logistic"fit left with a single class fitted without a message, and is now refused; a regularised fit left with one class stops with a message saying so.update()andstep()on a model’s$fitrefit on the training rows. The stored call nameddata, which both functions evaluate in the caller’s environment: a script that called its full datadatarefitted an 18-row model on all 32 rows, and with nothing in scope calleddatathe refit failed with “‘data’ must be a data.frame, environment, or list”. The call now refers to the data it was fitted with and prints asdata = <environment>$data; a weighted fit, which 0.5.0 could not make, refits with its weights. The call names its function with its package, as inrpart::rpart, soupdate()works in a session that has not attached rpart, randomForest, e1071, nnet or gbm. A saved model holds the training data a second time: a tree fitted on 20,000 rows saves at 1.1 MB, against 0.6 MB in 0.5.0.predict()on an"xgboost","deep"or"boost"model computes a data-dependent term of the formula, such asscale(hp)orpoly(wt, 2), with the values the training data gave it, aspredict.lm()does. The term was recomputed on the rows being predicted, so a row’s prediction depended on the rows scored with it: formpg ~ scale(hp) + wtwithnrounds = 20, the fifth row ofmtcarspredicted 15.0 alone and 18.6 inside the full frame. With"boost"onmtcarsstacked twice,n.trees = 50andset.seed(1), row 5 predicted 14.72 alone and 17.28 inside the frame. A matrix-valued term such aspoly(wt, 2)failed to fit with boost (number of items to replace is not a multiple of replacement length); it now enters as one predictor per column, as with"forest". A transform that needs nothing from training, such aslog(hp), is fitted through gbm’s formula interface as before.tl_model(method = "boost")reads a formula variable that is not a column of the data from the formula’s environment, aslm()does, at fit and atpredict(). gbm rebuilt its predictors in its own environment, which reaches only the global one, sompg ~ wt + zwithzdefined inside a function failed withobject 'z' not found, and where a globalzexisted as well, gbm fitted and predicted on the global one while the response came from the data."xgboost","deep",tl_tune_xgboost()andtl_tune_nn()fit and score the response the formula computes. The first three fittedlog(mpg) ~ wt + hptompgitself, so"xgboost"predicted 21.0 for the Mazda RX4, whose log mpg is 3.04, andtl_tune_nn()scored its folds against the raw column.method = "logistic"fits a response the formula computes. It was read off the first column named, soI(mpg > 20) ~ wtandifelse(mpg > 20, "hi", "lo") ~ wtwere refused withLogistic regression needs a two-class response, but 'mpg' is numeric with 25 distinct values, andI(am + 1) ~ wtfailed withArgument mu must be a nonempty numeric vector. Its predictions, probabilities and coefficients are now those ofglm()on the same formula, a response computed as text or as numbers other than 0 and 1 being fitted as the factor it encodes, and the ROC, precision-recall, calibration and confusion plots score the computed response too. A computed factor whose first level no row has is refused, with a pointer todroplevels():glm()would have compared every row with that empty class.method = "polynomial"adds its terms to the formula as written. The formula was rebuilt from the response’s name and the term labels, solog(mpg) ~ wtwas fitted as a model ofmpg: row 1 ofmtcarspredicted 22.91 wherelog(mpg)is 3.04, and now predicts 3.11.offset()terms and- 1were dropped,poly(wt, 3)becamepoly(poly(wt, 3), ...), and a factor went intopoly()on its integer codes, so onirisSepal.Length ~ .predicted 6.96 for row 101, or 7.66 oncedroplevels()had renumbered the levels. Each numeric main effect is now replaced by its polynomial, and predictions matchlm()on the same formula written out by hand. A numeric term that is also part of an interaction keeps its own term and gainsI(x^2)up to the degree, so the interaction is coded as written:mpg ~ cyl_f * wt, withcyl_fa factor, returned anNAcoefficient forcyl_f8:wt, and now fitsmpg ~ cyl_f * wt + I(wt^2). Factors,I()terms and bases such aspoly()or a spline’s are left as written.predict()on amethod = "polynomial"model computes a data-dependent term such asscale(wt)with the training data’s centre and scale, so its predictions on new rows change. Inside thepoly()orI()the expansion wraps it in, the term was recomputed on the rows being predicted, so formpg ~ scale(wt) + hponmtcarsa single row predictedNaN, andmtcars[1:5, ]predicted 20.48 for its second row against a fitted value of 21.71. Cross-validation scored each held-out fold on its own centre and scale:tl_cv()onmpg ~ scale(wt) + hpatfolds = 4afterset.seed(1)reported a mean rmse of 2.43, and now reports 2.34."ridge","lasso"and"elastic_net"keep every predictor of a formula without an intercept. The design matrix’s first column was dropped as the intercept, sompg ~ wt + hp + disp - 1(or+ 0) was fitted onhpanddispalone, with no message. glmnet still fits its own intercept; passintercept = FALSEto fit without one."ridge","lasso"and"elastic_net"cross-validate a sequence of penalties.lambda = c(1, 0.1)fitted the path with no penalty chosen, andpredict()returned 64 rows for 32. The sequence now goes tocv.glmnet(), which chooseslambda_minandlambda_1sefrom it (set a seed for a reproducible choice), so the model predicts,tl_plot_regularization_cv()works for it, andtl_plot_regularization_path()marks onelambda.minand onelambda.1sein place of a dashed line at every penalty. A singlelambdais fitted as given, as before."ridge","lasso"and"elastic_net"reject an argument that neitherglmnet()norcv.glmnet()takes. Both discard names they do not know, so a misspeltstandardise = FALSEchanged nothing, andstratawas ignored the same way. A cross-validation argument such astype.measureorfoldidgiven with a singlelambda, where no cross-validation runs, is refused as well.nfolds,family,xandyare refused with a message saying what sets each:cv_foldssets the number of folds, and the response sets the family. Passed beside tidylearn’s own value, they failed with R’sformal argument "nfolds" matched by multiple actual arguments.relax = TRUEandgammaare refused too, because predictions and coefficients come from the unrelaxed fit;relax = TRUEfailed withno applicable method for 'family' applied to an object of class "NULL".weightsandfoldidwork with"ridge","lasso"and"elastic_net"on data with missing values. Incomplete rows were dropped from the predictors but not from these per-row values, so one missing value failed withnumber of elements in weights (32) not equal to the number of rows of x (31), orlogical subscript too longforfoldid. They now follow the rows the fit keeps, as inlm()."ridge","lasso"and"elastic_net"given a single predictor column, such asmpg ~ wt, stop with a message saying they need two, in place of glmnet’sx should be a matrix with 2 or more columns, which names an argument the caller never passed.predict()on an"svm"model returns one row per row ofnew_data, withNAonly where a predictor is missing. e1071’spredict.svm()dropped every row with a missing value in any column ofnew_data, the response and unused columns included, soOzone ~ Temp + Windonairqualityreturned 111 predictions for 153 rows, and the rows that came back no longer lined up with the input. A classification model scored oniriswith two missing values in an unused column returned 148 predictions for 150 rows. When no row has every predictor, every row isNA;predict.svm()failed withtest data does not match model !.tl_model(method = "forest")fits formulas with a transformed term, such asmpg ~ log(hp) + wtormpg ~ factor(cyl) + wt. randomForest’s formula interface fails on them withobject 'hp' not found, so for those formulas tidylearn builds the predictor frame itself and rebuilds it from the raw columns atpredict(), each term computed as in training:scale(hp)with the training centre and scale, andpoly(),ns()orbs()with the training coefficients, each of their columns a predictor of its own. A row therefore predicts the same alone as among other rows. Formulas of plain columns still go through randomForest’s formula interface.Values the method wrappers set themselves give way to the caller’s, where naming one failed with
formal argument matched by multiple actual arguments:probabilityandtypefor"svm",linoutfor"nn",verboseand, for regression,distributionfor"boost",maxitandtraceintl_tune_nn(), andverboseintl_tune_deep(). For classification,"boost"refuses adistributionother than the one it sets from the response, since its predictions read only bernoulli and multinomial fits.tl_model(method = "xgboost")keeps a row with a missing predictor, which xgboost routes itself, and drops a row with a missing response. A missing predictor failed withThe length of labels must equal to the number of rows in the input data.tl_model(method = "xgboost")puts booster settings passed through..., such asmax_leaves,tree_methodorbooster, intoparams. xgboost 3.x moved them there itself but warned that doing so will become an error. A setting tidylearn also sets, such asobjectiveoreval_metric, takes the caller’s value; xgboost 3.x had refused anobjectivepassed this way.early_stopping_roundsis refused by name unless a validation set is passed asevals(watchlistbefore xgboost 3.0). It failed with xgboost’sFor early stopping, 'evals' must have at least one element. A validation set passed under the other version’s name is renamed, intl_tune_xgboost()too, where it reaches the final fit; xgboost 3.x renamedwatchlistitself with a warning that doing so will become an error.tl_model(method = "xgboost", booster = "gblinear")leaves out the tree parameters tidylearn sets by default (max_depth,subsample,colsample_bytree,min_child_weight,gamma), unless they are named. Every such fit printed xgboost’sParameters: { ... } are not used.predict(iterationrange = )on an"xgboost"model uses the rounds it documents,c(start, end)inclusive of both ends, on every xgboost version. Before xgboost 3.0, which reads the end as exclusive,c(1, 5)predicted from four rounds;ntreelimitwas translated the same way. A range past the model’s last round, or with its start after its end, is refused by name, where xgboost 3.x stopped withCheck failed: end <= model.BoostedRounds().predict()on a multiclass"nn"model returnsNAfor a row with a missing predictor. The default type andtype = "class"failed withinvalid subscript type 'list'.predict()on a"deep"model scores data without the response column, which failed withobject 'Species' not found; returnsNAfor a row with a missing predictor, where three rows in came back as two; and reads factors on their training levels, so new data holding fewer levels is scored. keras no longer prints a progress bar on every prediction.
Metrics and evaluation
tl_evaluate()scores a model against the response its formula fits, the left-hand side evaluated on the scored rows. It read the raw column, so a model oflog(mpg) ~ wt + hpcompared log-scale predictions with miles per gallon: rmse 18.1 on the training data for a fit whose residual rmse is 0.106, and withset.seed(1)tl_cv(folds = 3)reported rmse 18.0 and rsq -11.7 (now 0.133 and 0.731).tl_cv(),tl_compare_cv()and the tuners score throughtl_evaluate(), so they move with it: withset.seed(1)and 3 folds,tl_tune_grid()on a tree oflog(mpg) ~ .gave everycpthe same 18.13 and now gives the log-scale 0.220.tl_evaluate()reads the observed classes against the classes the model was trained on, and takes the model’s second class as the positive one. A test split ofiris[iris$Species != "setosa", ]still declares setosa, so every method stopped with “truth and estimate levels must be equivalent”, andtl_cv(),tl_compare_cv(), the tuners, pipelines andtl_auto_ml()stopped the same way whenever a fold lacked a class: a logistic model of mtcars’ 0/1amcrashedtl_cv(folds = 10)for seeds 1 to 4. Rows of a class the model never saw, which a fold whose training rows missed a class produces, are left out with a warning naming the class.tl_calc_classification_metrics(), which has no model to consult, takes its classes from the levels ofpredicted, aspredict()returns them, plus any other class it finds; a prediction factor built by hand without a class the truth holds stopped with the same yardstick error, and those rows now count as errors. Predictions passed as character were read against every level the truth declared, so the values change: for a tree fitted oniris[51:150, ], whoseSpeciesstill declares setosa, precision and recall were a three-class macro average of 0.943 and 0.940 and are now the binary 0.978 and 0.900.pr_aucagrees withyardstick::pr_auc(). The area was integrated from the curve’s first finite point rather than from recall 0, so everything before that point was lost: a tree on two iris species reported 0.074 where yardstick gives 0.964, and a perfect ranking of 5 positives in 20 scored 0.8. With constant probabilities thepr_aucrow was missing from the result. For more than two classespr_aucwas never computed and is now the average of the one-vs-rest areas, as"auc"is. The area intl_plot_precision_recall()’s subtitle comes from the same corrected calculation.tl_evaluate()refuses to score when no row is left, where it returnedNaNforaccuracy,rmseandmaewithout a message. That coversnew_datawith no rows, such as an empty test split, and rows that are all dropped: for a missing response, a missing prediction (as wherever a predictor is missing) or a class the model was not trained on, and the message counts each. The error has classtidylearn_no_scored_rows.tl_cv()catches it and leaves that fold out of the summary, with a warning giving the reason.tl_calc_classification_metrics()refuses an empty or wholly incomplete input the same way.tl_evaluate()andtl_calc_classification_metrics()refuse a metric name they do not compute, listing the ones they do.tl_evaluate(model, metrics = "RMSE"), a classification metric on a regression model andtl_cv(metrics = "RMSE")each returned a 0-row tibble without a message.metrics = "sensitivity"no longer adds arecallrow it was not asked for.tl_evaluate()andtl_calc_classification_metrics()drop a row missing its observed class, prediction or probability before computing any metric, as the regression metrics already did. AnNAin a predictor stopped"auc"with “‘predictions’ contains NA.”, and anNAresponse with “Not enough distinct predictions”, while"accuracy"dropped the same rows."auc"and"pr_auc"areNA, with a warning, when the scored rows hold a single class. ROCR stopped on them with “Number of classes is not equal to 2”, which abortedtl_compare_cv()(with its default metrics,set.seed(2)and 5 folds on mtcars’am) and, withset.seed(1),tl_tune_grid(metric = "auc", folds = 10). The fold now counts as unscored and the summaries average the others. For more than two classes, a class with no scored row gets anNAauc_<class>row and is left out of the averages, with a warning.tl_cv()checks its arguments.foldsmust be a whole number between 2 andnrow(data);folds = 2.5ran two folds without a message.weights,subset,offset,foldidandstratapassed through...are refused by name, astl_compare_cv()refuses them, since they hold one value per row and each fold fits a subset of the rows. A tree ignored them: onmpg ~ wt + hpwithset.seed(1), 3 folds andweights = seq_len(32) / 32, rmse was 4.84, the same as without weights. A linear model failed with “..1 used in an incorrect context”. An argument set toNULLis not refused. A one-column data frame, asy ~ 1uses, is kept a data frame in each fold, where it failed with “‘data’ must be a data frame”.tl_cv()gives the warning thatmethod = "logistic"converts a numeric 0/1 response to a factor once per run. It came once per fold, so a logistic model of mtcars’amatfolds = 5warned five times.tl_calc_classification_metrics()refusespredicted_probswithout a column for every class. A frame missing one was scored as a different problem: two classes with only theacolumn gaveaucandauc_aof 1. A vector is refused with the shape it needs, where it failed with “argument is of length zero”. A matrix with a column per class is accepted, read by column name, where it failed with “subscript out of bounds”. The function warns whenthresholdsare ignored, for more than two classes or without probabilities, and when"auc"or"pr_auc"is asked for by name without probabilities. The input and output shapes of it andtl_evaluate()are documented, including thethresholdcolumnthresholdsadds and the placeholder row an unsupervised model returns.
Tuning and model selection
-
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, where they failed withargument is of length zero.The count and the warning cover folds whose fit succeeded but could not be scored, as well as failed fits, and the warning says which happened. A fold goes unscored when the metric is undefined there – precision where nothing is predicted positive, auc on a fold holding one class – or when none of its rows can be scored, such as one where every predictor is missing, which is warned about by fold and parameter set. In 0.5.0 such folds were dropped from the mean without a mention: with
metric = "precision"onmtcars’sam,folds = 10andset.seed(1), both sets were scored on 7 of the 10 folds. A search in which every fit succeeded but no fold could be scored stops with a message saying so. tl_tune_grid()andtl_tune_random()decide the task from the response the formula computes, astl_model()now does, and treatmethod = "logistic"as classification whatever type the response is stored as.factor(am) ~ wt + hpis tuned as a classification and defaults to accuracy. The tuners read the raw 0/1 column, so in 0.5.0 that search was a regression scored by rmse (1.084 for both sets onmtcars,folds = 3,set.seed(1)), andmetric = "accuracy"was refused. They checked only for a factor or character response, so a 0/1 numeric response withmethod = "logistic"defaulted to"rmse", which a logistic model never produces, and the search failed withMetric "rmse" was not produced for this task.tl_tune_grid()andtl_tune_random()refuse aparam_gridorparam_spaceelement without a name, or two elements with the same name. An unnamed candidate vector reachedtl_model()positionally, where it was discarded, soparam_grid = list(c(0.01, 0.1))fitted the default tree twice (rmse 4.656 for both onmtcars,mpg ~ wt,folds = 3,set.seed(1)) and returned an emptybest_params.tl_tune_grid()andtl_tune_random()checkdata,method,folds,metric,maximizeandn_iterbefore fitting anything, and the message names the argument.metric = c("rmse", "mae")failed with “the condition has length > 1”,folds = 2.5with an rsample error aboutv, and a misspeltmethodwarned on every fit before failing with “Unknown method: trees”.n_iter = 0returned a fitted model with no error or warning, andn_iter = 2.5ran two iterations without a message; both are now errors.tl_tune_grid(),tl_tune_random(),tl_tune_nn()andtl_tune_deep()refuseweights,subset,offset,foldidandstratapassed through..., astl_cv()andtl_compare_cv()do. Each holds one value per row and cannot follow the rows into a fold. In 0.5.0 a tree search ignored the weights, a linear search warned “variable lengths differ” on every fold and still returned a model, and a forest search failed with “invalid ‘length’ argument”. Withweights,tl_tune_nn()failed with..1 used in an incorrect context, andtl_tune_deep()ran with keras ignoring them.tl_tune_xgboost()refuses the same arguments exceptweights, which it sets on the trainingxgb.DMatrix()and whichxgb.cv()splits with the rows; asubsetthere reached xgboost as a parameter it does not use, and the tuning ran on every row.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.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, counted the way randomForest counts them, with a warning; a matrix-valued term such aspoly(hp, 2)counts once per column, since the forest is fitted on its columns. randomForest reset an oversizedmtryin every fold while the results credited the value asked for.y ~ . - iddoes not countid: onmtcars[, c("mpg", "wt", "hp", "qsec")]plus an id column,mpg ~ . - idwithmtry = c(3, 4)left 4 uncapped, randomForest reset it to 3 in every fold, andbest_params$mtryreported 4 for a fit that used 3 (folds = 3,set.seed(1)). It is now capped at 3, and the duplicate set is evaluated once.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".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”.tl_plot_tuning_results()draws a parameter whose candidates are not single values, such as the deep grid’shidden_layersor an rpartparmslist, as a categorical one labelled as the verbose messages print it. Every plot type failed on them, the default scatter included. A categorical parameter with one value among the scored sets has importance 0, where the importance plot stopped with “contrasts can be applied only to factors with 2 or more levels”. The parallel plot draws one line per set: sets with tied scores shared a rank and were drawn as one line.tl_tune_grid()andtl_tune_random()givetl_model()’s note about the response once per search, from the final fit, andtl_compare_cv()does not repeat it, astl_cv()already did not. Every fold refit repeated it: a 2-set, 3-fold search onmtcarswithcyl ~ wt + hpprinted “Response ‘cyl’ has 3 unique numeric values” 7 times, and comparing two such models over 3 folds printed it 6 times. The warning that a numeric 0/1 response is converted to a factor formethod = "logistic"is likewise given once per search, and not bytl_compare_cv()’s refits, which repeated it on every fold. Other warnings from the fits still come through.tl_run_pipeline(),tl_auto_ml()andtl_stratified_models()give the note about the response once per call as well: on the first ten rows ofmtcars, a linear model and a tree ofmpg ~ wtover 3 folds printed it 8 times,tl_auto_ml(mtcars, cyl ~ wt + hp, time_budget = 10, cv_folds = 2)6 times, andtl_stratified_models(mtcars, mpg ~ wt + hp, k = 3, supervised_method = "linear")twice (set.seed(1)).tl_cv(),tl_compare_cv(),tl_tune_grid(),tl_tune_random()andtl_run_pipeline()warn once, before scoring, when every fold holds one row (folds = nrow(data)) and a metric being scored does not average to its leave-one-out value. rmse on one row is the absolute error, so the averaged rmse is the mean absolute error under rmse’s name: 2.52 for a linearmpg ~ wtonmtcarswithfolds = 32, the same as mae, where the leave-one-out rmse is 3.20. The warning says so, and that precision, recall, sensitivity, specificity and f1 are undefined on folds whose one row leaves nothing to divide by, and rsq, auc and pr_auc on every fold. The per-fold warnings from yardstick that it explains are no longer repeated. The scores are unchanged, and a run scoring only accuracy, mae, mse or mape gets no warning.tl_tune_nn()scores each two-class candidate by its own predictions. Every candidate was scored as if it predicted the first class for every row, so on two-classiriswithset.seed(1)and four folds every candidate scored 0.5, and the first in the grid always won. The candidates’ errors are 0.04 to 0.06.tl_tune_xgboost()refits its final model throughtl_model(), so the model records the tuned settings in$spec$argsandtl_compare_cv()refits each fold at them. It refitted at xgboost’s defaults: a model tuned tomax_depth = 1, eta = 0.01scored fold for fold the same as the default model. Arguments in...thatxgb.cv()takes go to it alone, and booster settings join every parameter set and the final fit;showsd = FALSEhad reached the finalxgb.train(), which warned that it did not recognise it. Caseweightsapply to the cross-validation and the final fit.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_compare_cv()refuses a model that a refit from its formula, method and arguments would not reproduce: one built bytl_semisupervised()ortl_anomaly_aware(), or atl_auto_ml()candidate fitted on PCA scores or cluster labels. A semi-supervised model trained on 15 labels was scored as a tree fitted on every training-fold label (accuracy 0.947 oniris,folds = 3,set.seed(1)), and an anomaly-aware model withaction = "flag"failed with “variable lengths differ (found for ‘is_anomaly’)”.tl_compare_cv()checks its arguments before fitting anything. A metric name it does not compute for the task is refused with the messagetl_evaluate()gives: a misspelt"acuracy"was dropped from the summary without a message, andmetrics = "rmse"on a classification model returned an empty summary.tl_compare_cv(mtcars, list())failed with “argument is not interpretable as logical”, a single model passed withoutlist()was read as three models that were not tidylearn models, and an unsupervised model failed the task check; each now gets a message saying what to pass.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_compare_cv()reportsNAwhere it has no value. A fold none of whose rows can be scored, such as one where every predictor is missing, hasNAfor each metric infold_metrics, with a warning naming the fold and the model; in 0.5.0 it scoredNaNand was left out of the summary without a message. A metric with no value on any fold summarises asNA: itsmean_valuewasNaN, and itsmin_valueandmax_valuewereInfand-Infwith a warning each.tl_cv()reports such a metric’s mean asNAtoo, as its note said it did: rsq ondata.frame(x = 1:10, y = 3)withfolds = 5summarised as meanNaN.tl_step_selection()withdirection = "forward"or"both"works with a formula using., and keeps a transformed response, the formula’soffset()terms and its intercept setting. It 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. The starting model was built from the response’s variable name, solog(mpg) ~ wt + hp + qsecselected a model ofmpg,mpg ~ wt + hp + offset(qsec)returnedmpg ~ wt + hpwith no offset, andmpg ~ wt + hp - 1came back with an intercept. The last two now givempg ~ wt + offset(qsec)andmpg ~ wt - 1, as backward selection did.tl_step_selection()fits every candidate model on the rows with no missing value in the formula’s variables, and a message gives the count. A variable with missing values changed the row count as it entered or left, so backward and both-direction selection stopped with “number of rows in use has changed: remove missing values?”, and forward selection compared the candidates on the complete rows, then returned a model fitted on more: with three missing values in a noise column added tomtcars, it compared on 29 rows and fitted on 32. The model keeps the data passed in, and the rows left out are recorded in the fit’sna.action.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.tl_step_selection()refuses a categorical response, saying that it selects linear models. A factor such asSpecies, or one the formula makes withfactor(am), had its codes fitted untilstep()stopped with “AIC is -infinity for this model, so ‘step’ cannot proceed”.tl_test_model_difference()acceptstest = "wilcox.test"as"wilcox". The diagnostics vignette recommended"wilcox.test"at small fold counts, a value the function rejected; it now usestest = "wilcox". The vignette and the help page give the fold count the exact signed-rank test needs: the smallest two-sided p-value over n folds is 2 / 2^n, which is 0.0625 at the vignette’s 5 folds, so it takes 6 to reach p < 0.05.tl_test_model_difference()pairs the two models’ scores by fold and uses the folds both have a value for, in the test and inmean_diff.mean_diffwas each model’s mean over its own scored folds: for fold scores (0.5, 0.6, NA, 0.8) and (0.4, 0.7, 0.9, 0.6) it was 0.017, where the mean paired difference is -0.067. A comparison with fewer than two such folds has anNAp-value, with a warning;t.test()stopped there, which discarded every other metric’s result.
Pipelines, AutoML and integration
tl_run_pipeline()standardises a numeric predictor only where the model the formula describes stays the same. It leaves alone a column used inside a function call, such aslog(),poly(),I()oroffset(); every column of a formula without an intercept; and the columns of an interaction whose lower-order terms are not all in the formula. Other plain terms are standardised as before. The formula was evaluated on the standardised columns, sompg ~ log(hp) + wtonmtcarstook the log of negative z-scores, warned “NaNs produced”, fitted the finallm()on 15 of the 32 rows (alog(hp)coefficient of -0.22, wherelm()gives -5.92), andtl_predict_pipeline()returnedNaNfor 5 of the first 6 cars.mpg ~ wt + offset(0.05 * hp)applied the offset per standard deviation ofhp, and its predictions were up to 8.5 mpg from those oflm()on the same formula;mpg ~ wt - 1fitted a line through a different origin. On data without missing values, a"linear"or"logistic"pipeline now predicts whatlm()orglm()predicts for the same formula.tl_run_pipeline()withvalidation = "split"stores the training split’s statistics in$preprocessing_statsand the preprocessed training split in$processed_data, since the models it keeps were fitted there. Both held full-data values, sotl_predict_pipeline()centredwton 3.217 (all 32 rows ofmtcars) for a model fitted under 3.279 (its 22 training rows), and its predictions for the test rows were up to 0.685 mpg from the ones the model was scored on (set.seed(3)). They now match.tl_pipeline()andtl_auto_ml()take the task from the response the formula computes, astl_model()does. They read it off the raw column, sofactor(am) ~ wt + hponmtcarsran as a regression ofam’s 0/1 codes: the pipeline scored its tree by rmse, and AutoML chose the task “regression” and ranked its candidates by rmse. Both now fit and score a classification.Best-model selection in
tl_run_pipeline()treatssensitivity,specificityandpr_aucas higher-is-better. They were ranked lowest-first, sobest_metric = "sensitivity"on two-class iris chose acp = 1stump scoring 0.400 over a tree scoring 0.898 (set.seed(10)). Thehigher_bettercolumn oftl_compare_pipeline_models()’s plot data does the same, and no longer marks a multiclass run’sauc_<class>rows lower-is-better. The pipeline, its comparison plot and thetl_auto_ml()leaderboard now take the direction from one list.tl_run_pipeline()warns about a cross-validation fold with no row it can score, such as one where every response is missing, and averages the model’s scores over the other folds. The fold’s scores came backNaNand were dropped from the average without a message. Withvalidation = "split"an unscorable test set is now an error, since there is nothing else to score; its scores wereNaN, and the run warned that every value wasNAand returned the first model.tl_pipeline()refuses an argument it does not take, andpreprocessing = list(dummy_encode = FALSE). A misspelt argument was swallowed by...:evalution = list(cv_folds = 2)leftcv_foldsat 5.dummy_encode = FALSEran the same models asTRUE, because each model’s fitting function encodes factors itself.tl_run_pipeline()andtl_stratified_models()give the logistic “Converting response variable to factor” warning once per call. They refit once per fold, model and cluster, and repeated it each time: two logistic specs over three folds on a 0/1 response warned eight times.predict()andtl_evaluate()on one oftl_auto_ml()’spca_*candidates accept the model’s own stored data, which holds the component scores in place of the raw columns, andtl_evaluate()andsummary()score that data for any model fitted on engineered features. Passing it back (tl_evaluate(model, model$data), or a plot that defaults to it) projected it a second time and failed with “PCA was fitted on 4 column(s) … but new_data is missing”. Whentl_auto_ml()fell back to training scores, those candidates dropped off the leaderboard.tl_auto_ml()checkstaskandmetricbefore fitting anything.metricmust be one of the task’s metrics thattl_evaluate()computes:"sensitivity"and"specificity"were refused only after every candidate had been fitted, and"roc_auc","kap","logloss"and"mlogloss"were accepted but never computed, so every score wasNAand the first model came back.taskmust be"auto","classification"or"regression"and agree with the response:task = "Classification"fitted the regression methods to a factor and scored every oneNA, andtl_auto_ml(mtcars, am ~ wt + hp, task = "classification"), which ran, is refused becauseamis numeric and every candidate but logistic regression fits it as a regression; write the response asfactor(am)instead. A formula response that is not a column ofdatais refused as well; every fit used to fail, leaving an empty leaderboard.tl_auto_ml()builds its PCA and cluster candidates from the predictors the formula names, and fits the cluster candidates with the cluster column added to the formula’s terms. The rotation and the k-means centres were fitted on every column but the response, soSpecies ~ . - idon iris put the excluded row id into both, and iris is sorted by class, so the id carries the class. With an explicit right-hand side,Species ~ Sepal.Length + Sepal.Width, the PCA used all four measurements, and the clustered candidates left the cluster column out of their formula, soclustered_treepredicted exactly whatbaseline_treedid. The number of clusters for a classification task counts the observed classes; a missing response value was counted as one.tl_explore()keepsmax_componentsprincipal components. It kept every component, somax_components = 2on iris gave 4, and the default onmtcarsgave 11 where it now gives 5.k_rangemust hold whole numbers from 2 to one less than the number of rows;k_range = 1:3failed with “incorrect number of dimensions”.tl_transfer_learning()takes a string formula and an explicit right-hand side, fits its PCA on the predictors the formula names, and acceptspretrain_method = "pca"only. A string gave “Response variable ‘NA’ not found”;Species ~ Sepal.Length + Petal.Lengthfailed with “object ‘Sepal.Length’ not found”, because the tree was fitted on the component scores under the raw-column formula; the documented"autoencoder"gave “Unknown method”; and an"mds"fit could not project the rowspredict()was given.tl_reduce_dimensions()names an unusablemethodorn_components:method = "kmeans"failed with “object ‘transformed’ not found”, andn_components = 6on iris with “Elements PC5 and PC6 don’t exist”.tl_compare_pipeline_models()names a requested metric the run did not score, which failed inside ggplot2’s faceting.summary()of a run pipeline printed the best model’s summary twice; it prints it once.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. The response is the one the formula computes, sofactor(am) ~ wt + hp + qsecis accepted andlog(mpg) ~ wt + hprefused as numeric. A computed logical such asI(mpg > 20), whichtl_model()fits as a regression, is refused with a pointer tofactor(), and so is a response that does not compute the propagated labels back from the column’s values, such ascut(mpg, 2), whose breaks follow the column’s range.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. Labelled rows whose label is missing are left out of the vote. A cluster whose labelled rows all had missing labels gave all its rows the response’s first level: with the labelled setosa rows set toNAand levelsc("virginica", "versicolor", "setosa"), the 45 unlabelled setosa rows were trained as virginica (set.seed(2)), and for a character response the same call failed with “Can’t combine NULL and non NULL results”. Those rows are now left out of training too, and the warning gives unlabelled rows and rows with a missing label separately. k is set to the number of classes the labelled rows carry, and labelled rows from fewer than two classes are refused. A logicallabeled_indicesand a string formula are also accepted now; the logical was matched as the positions 0 and 1.tl_semisupervised()keeps the response’s level order. The pseudo-labels were re-sorted alphabetically, so with levelsc("virginica", "versicolor")the model came back with versicolor first and virginica as the positive class: a logistic model’s.predfor the first versicolor row was 0.001, where it is now 0.999 (set.seed(1)).tl_semisupervised(),tl_stratified_models()andtl_anomaly_aware()cluster, or look for anomalies, on the predictors the formula names, so their results change for any formula that leaves columns ofdataout. They used every column but the response, so a column the formula excludes took part: with a row id added to iris andSpecies ~ . - id, the id was one of the k-means columns.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_stratified_models()takescluster_method = "kmeans","pam","clara"or"hclust", whose tree is cut atk."hclust"and"dbscan"failed with “unused argument (k = 2)”, and a"pam"fit could not predict even its own training rows. The training rows’ assignments are stored in$clusters, whichpredict()uses whennew_dataisNULL."dbscan", which picks its own number of clusters, is refused by name here and intl_semisupervised(), where it failed the same way.tl_stratified_models()handles a cluster whose rows all hold one class. Every classifier refuses a one-class response, sotl_stratified_models(iris, Species ~ ., k = 3)failed (set.seed(1)): k-means puts the 50 setosa rows in a cluster of their own. That cluster now gets no model and is listed in$single_class_clusters,predict()returns its class for its rows (probability 1 undertype = "prob"), and the other clusters’ models are built as before. This holds for a response the formula computes, such asfactor(am), whose single-class clusters were not found.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 probabilityNAwhere it now has 0.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_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","boost"and"nn"the call failed with..1 used in an incorrect context;"xgboost"warned “Passed unrecognized parameters: weights” and fitted without them, so its downweighted fits change; and"svm"ignored the weights. It now requires asupervised_methodthat applies case weights –"linear","polynomial","logistic","tree","ridge","lasso","elastic_net","forest","boost","nn"or"xgboost"– and refuses"svm"and"deep", which take no case weights. The"boost","nn"and"xgboost"fits matchgbm::gbm(),nnet::nnet()andxgboost::xgb.train()given the same weights. A forest reads them as sampling weights.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.tl_anomaly_aware()stops when DBSCAN marks every row as noise, namingeps,minPtsand the unscaled predictors. At the defaulteps = 0.5, all 32 rows ofmtcarsare noise formpg ~ wt + hp:action = "remove"failed inlm()with “0 (non-NA) cases”,"downweight"failed with “..1 used in an incorrect context”, and"flag"returned a model whoseis_anomalycoefficient wasNA.tl_anomaly_aware()names an invalidaction, which failed withobject 'model' not found, and no longer documents an"isolation_forest"method it never had.
Preprocessing, interactions and diagnostics
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 in place of bare vectors.tl_split()refuses astratifythat is not the name of one column.stratify = c("am", "vs")failed withthe condition has length > 1, and an unknown column is now named in the error.tl_split()refuses apropoutside (0, 1).prop = 1.5split 32 rows 31/1, because each side is kept to at least one row.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 withCan't recycle ..1 (size 150) 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, where it removed 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.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_prepare_data()refuses two inputs it used to misread. Ascale_methodit does not have, such as"zscore", reported “Scaling numeric features using method: zscore” and returned the data unscaled. A formula with no response, such as~ x1 + x2, took its first predictor as the response and passed it through unscaled.tl_prepare_data()refuses acorrelation_cutoffthat is not a single number greater than 0 and at most 1. No correlation exceeds 1, so a cutoff of 95, meant as a percentage, maderemove_correlated = TRUEremove nothing without a message.tl_prepare_data()no longer stops on an entirely missing numeric column withremove_zero_variance = FALSE, on an entirely missing factor (Can't recycle ..1 (size 6) to match ..2 (size 0)), on a single row or a column holding anInf(bothSelections can't have missing values), or on a grouped tibble (mpg must be size 11 or 1, not 32). The missing factor is left as it is, a column whose spread is not finite is left unscaled, and a grouped tibble is prepared as a whole and returned ungrouped. A response that is not a column of the data is named in the error:mgp ~ .failed with dplyr’sElement mgp doesn't exist. Its documentation says the statistics come from the data passed in, so preparing before splitting lets test rows shape them.tl_prepare_data()reports encoding, and records an encoding step, only for the variables it splits into dummy columns. A two-level factor is left as it is, yet one alone gave “Encoding 1 categorical variables”. Imputing and scaling are likewise reported and recorded only when a column is filled or scaled.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_auto_interactions(top_n = 0)adds no interaction.significant[1:0, ]selected the first row, sotop_n = 0still returnedy ~ a + b + z + a:z.top_nmust now be a single whole number of 0 or more. Theinteraction_testsandselected_interactionsattributes are set on every returned model, as documented; they were missing when nothing was significant or nothing was left to test.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_interaction_effects()andtl_plot_interaction()report a classification model on the probability of its second class. A forest or tree classifier predicts the class label by default, sofitwas a factor and the slopes were fitted to class labels (0.390 to 0.482 for a two-class iris forest fitted afterset.seed(1), under a run of warnings), and a contour plot of two numeric variables failed with'range' not meaningful for factors. A model with more than two classes is refused. A logistic model’s values are unchanged.tl_interaction_effects()andtl_plot_interaction()refuse a model variable that shares a name with a column they write their results to:fit,by_valueandby_label, and with intervalslowerandupper, for the effects;prediction, and with a band.lowerand.upper, for the plot. The results overwrote the variable:var = "fit"onmpg ~ fit * hpfailed withsubscript out of bounds, aby_varnamedfitor a held variable namedlowercame back replaced by the predictions or the interval, and a variable namedpredictionwas plotted as the predictions themselves.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_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.tl_test_interactions()andtl_auto_interactions()no longer treat the variable inside anoffset()as a candidate predictor. Formpg ~ wt + hp + offset(log(disp)),tl_test_interactions()also testedhp:dispandwt:disp, andtl_auto_interactions()could add an interaction with the offset’s variable, returning formulas such asy ~ a + b + a:e + offset(log(e)).tl_test_interactions()andtl_auto_interactions()refuse a categorical response.lm()cannot fit one, and a two-class factor response returned a row ofNaNafter a run of warnings. A logical response is still fitted as 0/1.A non-syntactic column name, such as
car weight, works intl_detect_outliers(method = "cook"), asvarintl_interaction_effects(), and asvar1orvar2intl_test_interactions(). Each pasted the name into formula text, which failed withunexpected symbol.tl_check_assumptions()reads a factor’s generalised VIF on the scale of an ordinary one.car::vif()returns a table of GVIF, Df and GVIF^(1/(2*Df)) when a term has more than one degree of freedom, and the maximum was taken over the whole table:mpg ~ wt + carbwithcarba six-level factor reported “Maximum VIF: 5” and multicollinearity, fromcarb’s 5 df, while both GVIFs are 1.6. The check now uses GVIF^(1/Df), which equals the VIF for a one-df term.tl_check_assumptions()no longer tests a logistic model for normal residuals or constant variance, neither of which logistic regression assumes. Shapiro-Wilk on the deviance residuals ofam ~ wt + hpgave p = 0 with advice to transform the response, andlmtest::bptest()tested a linear probability model. Both checks now have aNULLcheck and a note saying why, so that model goes from 3 violations in 6 checks to 2 in 4.tl_check_assumptions()counts a check its test could not decide as neither satisfied nor violated. A model with fewer than four distinct fitted values leaves the linearity checkNA, and the verbose summary failed withmissing value where TRUE/FALSE needed; withverbose = FALSEthe status read “NA assumption(s) appear to be violated”. Such a check is now reported as UNKNOWN. A one-factor model such aslen ~ supphas two fitted values that floating-point noise made look like four, so its linearity test ran and reported p = 1; it is nowNAtoo.tl_check_assumptions()always returns its multicollinearity check. Whencar::vif()could not run, the fallback result was assigned inside the error handler and lost, sompg ~ wtcame back with nomulticollinearityelement and five checks instead of six. The fallback counts the fit’s terms, so ay ~ .formula is no longer read as one predictor.tl_check_assumptions()no longer advances the session’s random number stream. It calledcar::durbinWatsonTest(), which bootstraps a p-value the function never read. The Durbin-Watson statistic is now computed directly, matcheslmtest::dwtest(), and is reported whether or not car is installed.tl_influence_measures()works on a rank-deficient fit and on one fitted withna.action = na.exclude.dfbetas()has no column for an aliased coefficient, and looping overcoef()failed withsubscript out of bounds; underna.excludeevery measure is padded back to all rows, and building the result failed witharguments imply differing number of rows: 30, 32(116, 153forOzone ~ Temp + Windonairquality).tl_check_assumptions()failed on both fits too: withsubscript out of boundson the rank-deficient one andresiduals include missing valuesunderna.exclude. The default thresholds count the coefficients the fit estimated.tl_detect_outliers()flags and counts the right rows when a value is missing. Withmethod = "cook"the fit dropped the incomplete row and its 31 distances were recycled into a 32-row flag matrix: one missingwtinmtcarsflagged nine rows where four have a Cook’s distance above 4/32. Those four are now flagged, and the incomplete row’s flags areNA. For every methodany_outlierandoutlier_counts$totalskip a missing flag: onairqualitythe total wasNAwhileoutlier_indiceslisted five rows, and it is now 5. A single row no longer fails withdim(X) must have a positive lengthunder"iqr"or"z-score".tl_diagnostic_dashboard()refuses a model whose fit is not anlmorglmbefore drawing anything. A tree failed partway through withno applicable method for 'rstandard' applied to an object of class "rpart".
Unsupervised learning
A one-sided formula for an unsupervised method names columns only. A term such as
log(Sepal.Length)is an error naming it; PCA used to run on the raw column instead, centred at 5.84, the mean ofSepal.Lengthitself.~ . - xnow means every numeric column butx, where it failed with “undefined columns selected”. A name that is not a column of the data, as in~ wt + zz, or~ . + zwithzin the caller’s session, is an error that names it; it failed with “undefined columns selected” too.-
tl_model()warns, naming the column, when an unsupervised formula names a non-numeric column the method cannot use: any such column for"kmeans","pca"and"clara", and for"mds","pam","hclust"and"dbscan"unless the distance is Gower.~ Sepal.Length + Speciesclustered on Sepal.Length alone, and PCA returned two components for three named columns, without a message. The fit is unchanged. For the four methods that take a distance the warning points to the Gower distance, which uses the column; a~ .formula still means the numeric columns and stays quiet.A non-numeric column selected by
colsis left out with a warning naming it wherever the method uses numeric columns only, which is everywhere but a Gower distance, and a selection with no numeric column is refused by name.tidy_kmeans(),tidy_pca(),tidy_knn_dist()andtidy_dbscan()failed on such a column inside their backends (NA/NaN/Inf in foreign function call,'x' must be numeric or complex,x has to be a numeric matrix,all data in x has to be numeric),tidy_hclust(),tidy_pam()andtidy_dist()dropped it from the distance without a message whiletidy_pam()’s$medoidsandtidy_hclust()’s$datakept it (both now leave it out), andtidy_dist(iris, cols = "Species")returned a distance matrix ofNA, whichtidy_hclust()andtidy_pam()then refused as missing values.tidy_dist(), and sotidy_mds(), refuse data with no numeric column in the same words. The unsupervised functions ignore dplyr grouping. Selecting the numeric columns of a grouped tibble brought the grouping variable back (“Adding missing grouping variables”), so on
group_by(iris, Species)tidy_clara()clustered on Species’ factor codes and partitioned 14 of 150 rows differently,tidy_hclust()andtidy_mds()worked from distances 1.118 times too large (Species coerced toNA),tl_model(method = "kmeans"),calc_wss(),tidy_gap_stat()andtidy_silhouette_analysis()failed withNA/NaN/Inf in foreign function call,tidy_dbscan()withall data in x has to be numeric,tidy_knn_dist()withx has to be a numeric matrix, andcalc_validation_metrics()with'x' must be numeric or complex. A grouped tibble now gives the fit of the same data ungrouped, throughtl_model()and thetidy_*(), distance, k-NN and validation helpers.tidy_dbscan()andtl_model(method = "dbscan")cluster on thedistanceasked for. It was ignored: oniris[, 1:4]witheps = 0.5andminPts = 5,distance = "manhattan"gave the Euclidean answer of 2 clusters and 17 noise points, wheredbscan::dbscan()on Manhattan distances finds 3 clusters and 91 noise points. Any methodstats::dist()accepts works, and"gower".Gower distance uses the factor columns the caller selected.
tidy_hclust(distance = "gower")andtl_model(method = "hclust", distance = "gower")kept only the numeric columns, with or without a formula, so even a factor named in~ num + facnever reached the distance. For PAM withmetric = "gower",~ .expanded to the numeric columns, and the fit disagreed with the no-formula one on 8 of 20 rows of a mixed example. With Gower, no selection and~ .mean every column,~ . - xevery column butx, and the heights and clusterings matchcluster::daisy()’s distances.tidy_dbscan(distance = "gower")takes every column the same way.tidy_gower()givesNAfor a pair of rows with no variable observed in both, ascluster::daisy()does. It gave 0, sotidy_gower(data.frame(x = c(1, NA, 3), y = c(NA, 2, 5)))called rows 1 and 2 identical andtidy_pam()grouped them together.tidy_pam()andtidy_hclust()now stop on undefined distances and name the pairs of rows, where they clustered them as identical.tidy_dbscan()names what is missing when its data has missing values: on coordinates, the columns holding them; on any other distance, the pairs of rows with no variable observed in both.dbscan()stopped withdata/distances cannot contain NAs for dbscan (with kd-tree)!. Missing values that leave every pair a shared variable cluster on a distance that tolerates them, such as"manhattan".calc_validation_metrics()leaves DBSCAN noise (cluster 0) out of every measure, and reports it in a newn_noisecolumn, socompare_clusterings()gains the column too. Onlykexcluded noise before. Fortidy_dbscan(iris[, 1:4], eps = 0.5, minPts = 5), whose clusters hold 49 and 84 rows, it reportedmin_size17 (the noise count),avg_size50, average silhouette 0.486 and total WSS 170.5; it now reports 49, 66.5, 0.735 and 90.7. A single cluster givesNAsilhouette columns where it failed withincorrect number of dimensions.calc_validation_metrics(),compare_clusterings()andtidy_silhouette()accept factor and character cluster labels.cluster::silhouette()refused them with'round' not meaningful for factors, which turned away theclustercolumn ofaugment_kmeans()’s own output. Labels that read as whole numbers keep their values, soaugment_dbscan()’s"0"is still noise;tidy_silhouette()reports other labels as given.calc_validation_metrics()refusesdatawith no numeric column, where it summed squares over no columns and reportedtotal_wss = 0, a perfect score:calc_validation_metrics(rep(1:3, each = 50), iris["Species"])gave 0 whereiris[, 1:4]gives 89.30.compare_clusterings()refuses the same data, where it also reported 0 with adist_mat, or failed insidesilhouette()withNA/NaN/Inf in foreign function callwithout one. Given adist_matand nodata, the silhouette is still scored on its own.tidy_gap_stat()’sk_firstmaxis the first local maximum of the gap,cluster::maxSE(method = "firstmax"). It was computed withwhich.max(), maxSE’s"globalmax", so it always equalledk_globalmax: 8 foriris[, 1:4]withmax_k = 8,B = 10,nstart = 5andset.seed(1), where firstmax gives 5.print()labels globalmax as the most liberal of the three and firstmax as the middle ground.optimal_hclust_k(method = "gap")refuses a tree built withdistance = "gower"on non-numeric columns, naming them and pointing tomethod = "silhouette".clusGap()draws its reference data uniformly over each numeric column, so the refit dropped the factors and returned a gap curve for clusterings the tree never made. A Gower tree on numeric columns, and a tree on any other distance, are unaffected.suggest_eps()reads the k-NN distance atk = minPts - 1, the neighbours a core point needs besides itself, asdbscan::kNNdistplot(minPts = )does. It usedk = minPts, the radius for one more neighbour:suggest_eps(iris[, 1:4], minPts = 5)$epswas 0.758 and is now 0.718, the 95th percentile ofdbscan::kNNdist(k = 4).minPtsbelow 2 is an error.tidy_pca(method = "princomp", center = FALSE)warns thatprincomp()always centres and recordscenter = TRUEin$settings. It returned centred scores while$settings$centersaidFALSE.tidy_mds()returns the dimensions classical MDS supports whenndimasks for more:cmdscale()keeps only dimensions with a positive eigenvalue, and namingndimcolumns failed withlength of 'dimnames' [2] not equal to array extent.ndimoutside 1 to n - 1 is refused by name.print()on atidy_mdsresult counts itsDimcolumns: a fit on a tibble, which has no row labels, printed “Dimensions: 1” for two.tidy_clara()refuses a dist object with a message pointing totidy_pam(). It passed the distances tocluster::clara(), which samples observations and takes no distance matrix, and failed there with'x' is a "dist" object, but should be a data matrix or frame.The
colsargument oftidy_kmeans(),tidy_pam(),tidy_hclust(),tidy_dbscan(),tidy_knn_dist(),tidy_dist()andtidy_pca()takes tidy-select expressions, as documented. Only a character vector worked:cols = c(Sepal.Length, Sepal.Width)failed withobject 'Sepal.Length' not found, andstarts_with()failed too.tidy_knn_dist(),suggest_eps(),plot_knn_dist(),explore_dbscan_params()andtidy_dbscan()accept a numeric matrix of coordinates. They failed withno applicable method for 'select'.tidy_knn_dist(), and through itsuggest_eps()andplot_knn_dist(), refuse data with no numeric column, or with a missing value, in words that say so, astidy_dbscan()does; dbscan stopped withthe provided data has 0 columns!ordata/distances cannot contain NAs for kNN (with kd-tree)!.tidy_clara()refuses data with no numeric column the same way, wherecluster::clara()reportedEach of the random samples contains objects between which no distance can be computed.standardize_data()standardises a rowwise tibble over its whole columns.mutate()works on a rowwise tibble one row at a time, and a single value has no spread, so every standardised value came backNaN(withscale = FALSE, every centred value was 0). The rowwise structure and its identifier columns are kept. A grouped tibble is still standardised within each group, which the documentation now says.plot_clusters()andcreate_cluster_dashboard()keep the cluster column off the default axes. As the first numeric column, an integer cluster column became the x axis.create_cluster_dashboard()now skips the scatter plot when the data has fewer than two numeric columns besides the cluster column. With fewer than two numeric columns counting the cluster column it failed with “grobsmust be a single grob or a list of grobs”; with an integer cluster column and one other numeric column it plotted that column against the cluster labels. It leaves out the silhouette line when the metrics have none, where it warned about an unknown column and printed NA, and returns its plots as a named list (clusters,sizes,metrics).plot_dendrogram()draws atl_model(method = "hclust")model. It passed the model toplot(), which reachedplot.tidylearn_model()and failed with “unused arguments (main = …, xlab = …)”. Another tidylearn model is refused by name.plot_knn_dist()labels a percentile that is not a whole percent:percentile = 0.975failed withinvalid format '%d', and so did 0.57, since0.57 * 100is 56.99999999999999. It now reads “(97.5% percentile)”.recommend_products()suggests only items the basket lacks, each once, so its recommendations change. Rules whose right-hand side was already in the basket were returned: for the basketwhole milk,other vegetables,yogurt,root vegetables,tropical fruiton theGroceriesrules atsupport = 0.001,confidence = 0.5it returned{yogurt}and then{other vegetables}four times, and now returns no rows, since every rule that fires suggests an item the basket holds. A product suggested by several rules is listed once, with its highest-lift rule. Rules are matched on their item lists, so an item name containing a comma, such as"salt, iodised", can fire a rule; splitting the label on","cut it in two.filter_rules_by_item()andfind_related_items()match whole items, so they return fewer rules.grepl()on the labels matched"coffee"inside"instant coffee", returning 84 of theGroceriesrules where 80 contain coffee, and"ham"inside"hamburger meat", 207 rules where 102 contain ham; 19Groceriesitems occur inside other item names.itemmust be a single name: a vector was matched on its first element, with a warning.A frequent-itemsets result from
tidy_apriori()prints and inspects as itemsets.print()read the rules table, which itemsets do not have:tidy_apriori(Groceries, support = 0.05, target = "frequent itemsets")printed nine min/max warnings andSupport: Inf - -Inf, then failed withno applicable method for 'slice', asinspect_rules()did. The result gainsitemsets_tbl(itemset_id,itemset,size, the quality measures and anitemslist column), whichprint()andinspect_rules()read;inspect_rules()sorts itemsets by support by default, since they have no lift.summarize_rules()refuses an itemsets result with a message saying it holds itemsets; it returnedInf,-InfandNAsummaries with nine warnings.recommend_products(),filter_rules_by_item(),find_related_items()andvisualize_rules()refuse it with the same message, where they failed withno applicable method for 'filter' applied to an object of class "NULL"or, forvisualize_rules(), an arules method-dispatch error.visualize_rules()plots thetop_nrules with the highest lift, so it draws other rules. It took the firsttop_nin mining order under the subtitle “Top 50 rules (colored by lift)”: on theGroceriesrules atsupport = 0.001,confidence = 0.5it plotted lifts 2.04 to 16.7, where the 50 highest run from 8.08 to 19.0. The subtitle now reads “Top 50 rules by lift”. The other methods draw the same top-lift rules.inspect_rules(decreasing = FALSE)returns thenlowest-ranked rules, lowest first, as arules’head(by = )does. It took thenhighest and only reversed their order: lifts 16.4, 16.7 and 19.0 forby = "lift", n = 3on theGroceriesrules, where the three lowest are 1.96.
Plots and tables
plot()on a"ridge","lasso"or"elastic_net"model drawstype = "importance"withtl_plot_importance_regularized(), where it said the plot was not implemented.type = "residuals"works for every regression method, andtype = "diagnostics"is refused by name outside"linear","polynomial"and"logistic"(the models fitted bylm()orglm()), where it failed insiderstandard(). Residuals came fromfitted(), which isNULLon a glmnet fit, so the plot was returned and failed when printed; they now come from the model’s predictions, on the scale the formula fits. Residuals against predicted values also failed for a linear model whose data had a missing value, withCan't recycle input of size 32 to size 31.plot()withtype = "roc","precision_recall","calibration"or"confusion"scores rows against the classes the model was trained on. The classes were read off the data, so a test split ofiris[iris$Species != "setosa", ]still declaring setosa was refused as multiclass, and a test factor with its levels reordered switched the positive class of the precision-recall and calibration curves. Rows missing a response or a prediction are left out with a warning giving the count: ROC and precision-recall stopped with ROCR’s'predictions' contains NA, and the confusion counts summed to 30 of 32 with no message. ROC and precision-recall need rows of both classes and now say so, where ROCR reportedNumber of classes is not equal to 2.plot(type = "actual_predicted")compares on the scale the model was fitted on and leaves incomplete rows out of its statistics, with a warning giving the count. Forlog(mpg) ~ wtit plotted rawmpgagainst predictions oflog(mpg)and reported a correlation of 0.868, where the fitted scale gives 0.893, and one missing value made the subtitleCorrelation: NA, R-squared: NA.tl_plot_intervals()works withy ~ ., which failed withargument 1 is not a vector, and plots the observed response on the scale of the bands: forlog(mpg) ~ wtit drew rawmpgagainst bands forlog(mpg). A model without anlm()fit is refused by name, where"ridge"failed asking fornewx. Data without the response gets the bands alone, and a row with a missing predictor is left out with one warning, where ggplot2 gave four.Importance for
"ridge","lasso"and"elastic_net"no longer depends on the units of each predictor, so its values change intl_table_importance(),tl_plot_importance_regularized()andtl_plot_importance_comparison(). It was the absolute coefficient, so rescalinghpto hundreds multiplied its importance about a hundredfold without changing a prediction. Each coefficient is now multiplied by the standard deviation of its design column over the rows the model was fitted on. 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, where it gave an empty table and amax()warning.Importance for a random forest whose permutation importance is negative for every feature keeps randomForest’s order.
tl_table_importance(),plot(model, type = "importance")and the importance comparison divided by the largest value, itself negative, so %IncMSE of -6.20, -0.87 and -6.61 (set.seed(7), three noise predictors) became 709, 100 and 756 and the least useful feature ranked first. With no positive value the largest magnitude now sets the scale: -93.8, -13.2 and -100. Any set with a positive value is scaled as before.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.plot(model, type = "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.Importance for a tree with no splits stops with “No feature has non-zero importance: the tree has no splits”, in
tl_table_importance(),plot(model, type = "importance")and the dashboard’s importance panel. They failed withColumn `importance` not found in `.data`, and so did anytl_plot_importance_comparison()that included the tree, which now draws with that tree at zero.tl_plot_importance_comparison()counts as zero a feature a model was given but did not use. The ranking averaged each feature over only the models that kept it, so a feature a lasso dropped could outrank one both models used. A tree, forest or boost model is given the variables of an interaction, never the interaction itself, sowt:hpgets no bar from it.Importance for
method = "boost"names the columns gbm fitted on, so the rows it reports change. gbm names its relative influence after the formula’s terms but computes it over the variables they use, so formpg ~ wt * hp + qsectl_table_importance(),plot(model, type = "importance")and the importance comparison listed awt:hpat zero, a column gbm never had, and formpg ~ wt:hp + qsecthey failed with gbm’s “row names contain missing values”. Both now reportwt,hpandqsec, each with its own influence.tl_plot_importance_comparison()names a factor predictor once for every model. randomForest, rpart and gbm report the factor (Species) and glmnet and xgboost its design columns (Speciesversicolor,Speciesvirginica), so a forest and a lasso ofSepal.Length ~ .on iris (set.seed(1)) drew 9 bars over 6 features: the one predictor appeared three times, each with one model’s bar. Design columns now map back to their formula term, which takes its largest column, and the same pair draws 8 bars over 4 features. A non-syntactic name was split the same way: rpart reportscar weightand glmnet`car weight`, so a lasso and a tree showed two features; they now show one, named without backquotes. A lasso that penalised every predictor away dropped out of the plot with only amax()warning; it now stays at zero, with a warning naming it.tl_plot_importance_comparison()refuses repeatednames.names = c("A", "A")drew both models’ bars in the same places; it is now an error, as it already was fortl_plot_model_comparison()andtl_table_comparison().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.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_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_table_comparison()andtl_plot_model_comparison()with nonew_datascore the models on their training data only when every model was fitted on the same data. Both scored every model on the first model’s training rows: two linear models fitted onmtcars[1:20, ]andmtcars[13:32, ]were compared on the first one’s rows, which the second had partly never seen, without a message. Models fitted on different data are now an error naming them and asking fornew_data. Models fitted on one data frame with different formulas still share it. Atl_auto_ml()PCA or cluster candidate stores its engineered features in place of the rows it was fitted on, so with a PCA candidate listed first the first model’s stored data was the PCA scores, and the comparison failed inside the candidate with “PCA was fitted on 4 column(s) … but new_data is missing”. Every model is now scored on the raw training rows of the first model that stores them, a cluster candidate included, and a PCA candidate through its own projection, as it already was when listed after one. Models fitted on PCA scores alone store no raw rows, and still neednew_data.tl_plot_lift()andtl_plot_gain()no longer depend on row order, so their curves change. 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.-
tl_plot_lift(),tl_plot_gain()andtl_table_confusion()read the observed classes against the model’s. The positive class came from the scored data’s level order, so reorderingam’s levels toc("1", "0")made the gain chart ofam ~ wtrank mtcars by P(am =- and count the 19 automatic cars as responders, where the model’s positive class is the 13 manual ones. Lift and gain decide binary from the model: a test split of
iris[iris$Species != "setosa", ]still declares setosa, so both charts called a binary model multiclass and refused it; they now draw it. The confusion matrix of a two-class model gained a row of zeros for a class the scored data declared but did not hold, and a row for each class the model was never trained on. It now has one row and one column per class the model was trained on. Rows of any other class are left out with a warning, where lift and gain failed. Lift and gain on scored rows with no row of the positive class are an error naming the class, where every cumulative value was 0 / 0. Anew_datawithout the response column is reported by name (“Response variable ‘am’ not found in the evaluation data”) where the charts said the model was not binary and the confusion table failed with “all arguments must have the same length”.
- and count the 19 automatic cars as responders, where the model’s positive class is the 13 manual ones. Lift and gain decide binary from the model: a test split of
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_dashboard()’s regression panels work for every method. The Residuals panel passed the evaluation data totl_plot_residuals()as its plot type and failed with “the condition has length > 1” for linear, lasso and forest models; it now plots residuals of the dashboard’s data against predictions. The Diagnostics panel printed the four diagnostic plots in turn and showed only the last, Residuals vs Leverage; it now arranges all four (with gridExtra installed). For a model that is not linear or polynomial it failed insiderstandard(), and now says the plots need a linear or polynomial model. The Predictions table and the Residuals panel read the response as the formula’s left-hand side: alog(mpg) ~ wt + hpmodel listed mpg beside log-scale predictions, with residuals of one minus the other.tl_plot_cv_results(metrics = )draws only the metrics asked for. The mean lines were not filtered, sometrics = "rmse"still laid out a panel for every metric the folds scored, with fold lines in rmse alone. A requested metric the folds did not score is named in a warning, and asking only for absent metrics is an error listing the ones available.The source notes of the
tl_table*()tables count the rows the table describes. Every note reportednrow(model$data): coefficients forOzone ~ Temp + Windon airquality said n = 153 wherelm()used 116, and metrics and confusion tables scored on a 10-row test set said n = 22, the training rows. Coefficient and importance tables now count the rows the fit used, and metrics, confusion and comparison tables the rows scored; a comparison whose models scored different rows gives each model’s count. An xgboost fit withweightscounts only the rows with a weight, which are the rows xgboost trains on.tl_table_clusters()summarises only the columns an hclust or dbscan fit used. A dbscan model of~ Sepal.Length + Sepal.Widthoniris[, 1:4]tabulated the means of all four measurements, while the kmeans table already showed only the fitted two. The cluster table no longer counts dbscan’s noise points as a cluster, and now averages hclust and dbscan tables around missing values. A long formula no longer splits a table’s source note in two, andtl_table_coefficients()warns about arguments it does not use.tl_plot_regularization_path()draws a multiclass model, one panel per class; it failed withTibble columns must have compatible sizes. Its feature labels were drawn 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. The room for labels grows with the longest name, soSpeciesversicoloris no longer cut off at the panel edge at 7 x 5 inches. When every path is labelled – five or fewer predictors at the defaultlabel_n– they were all drawn grey, thin and faint, because the colour, width and transparency scales matched their values by position; they are now drawn in the accent colour.tl_plot_regularization_path()refuses a model fitted at a singlelambda. Each term was one point, so the plot drew no line, under a subtitle naming alambda.minandlambda.1seno cross-validation had chosen. A model fitted along several penalties without cross-validation, as earlier versions fitted alambdasequence, is drawn without thelambda.minandlambda.1selines and says so.tl_plot_regularization_cv()labels its axis with the measure cross-validation used. It read “Binomial Deviance” for every classifier, a multinomial one included, and “Mean Squared Error” for every regression, whatevertype.measurechose.tl_plot_xgboost_tree()draws the treetree_indexnames. On xgboost 3.x it drew the first tree whatevertree_indexwas, and warned thattree_indexwill become an error. Atree_indexpast the model’s last tree is refused with the number of trees.tl_xgboost_shap()reports a multiclass model’s SHAP values per class: one block of rows per class, with aclasscolumn. It labelled a flattened slice of xgboost 3.x’s row x class x feature array, so oniristhe column calledPetal.LengthheldSepal.Length’s values for virginica, and 11 of the 18 columns were namedNA,NA.1and so on. It also scores data without the response column, which failed withobject 'mpg' not found, and appliestrees_idxas a run of boosting rounds, where it was dropped.tl_plot_xgboost_shap_dependence()takes its SHAP values and its feature values from the same rows. It drew two independent samples, so fory = 10xover 400 rows the plotted correlation between the feature and its SHAP value was near zero. Both SHAP plots draw a multiclass model one panel per class.tl_plot_svm_boundary()takes its default axes from the model’s numeric predictors, and fills in only an axis that was not named. They were the first two numeric columns of the data, soSpecies ~ Petal.Length + Petal.Widthwas drawn over the sepal columns the model never used. The 0.5 probability contour of a two-class model is drawn; a check against the wrong fields of the e1071 fit kept it from ever appearing.tl_plot_partial_dependence()draws the mean prediction at each grid value for a regression model. It tookmean()of the tibblepredict()returns, so every regression curve, the function’s own example included, wasNA, with a warning for each grid value.tl_plot_deep_architecture()draws through theplot()method keras provides for its models. It calledkeras::plot_model(), which keras 2.x does not have, so every call failed withobject 'plot_model' not found.
Data ingestion
tl_read()names the cause for sources it cannot read. A web URL other than GitHub or Kaggle was passed to the CSV reader and reported as “File not found”; it is refused with a message to download the file first, with or withoutformat. Hosts are matched on the URL’s host name, sohttps://mirror.example.org/github.com/data.csvis no longer taken for GitHub.s3://bucket/archive.zipwas looked for as a local zip file, andtl_read_s3()andtl_read_github()now say they cannot read zip archives. AnNAor emptysourceis refused by name; it failed with “missing value where TRUE/FALSE needed”. URL schemes match in any case, soS3://reaches the S3 reader.tl_read_dir()reads files only. Withoutrecursive = TRUE,list.files()also returns folders, so a folder whose name matched, such asold.csv, was read as a directory and its rows were added to the result.tl_read_zip(file = )reads the member it was asked for.file = "train.csv"readfull_train.csvwhen the archive held both, because the name was matched as part of each member’s base name and the first match in file order won, with no message under.quiet = TRUE. An exact path within the archive now wins, then an exact file name, then part of a path, and a name that matches several members at the deciding step is an error listing them. A member in a folder can be named by its path, such as"2024/sales.csv"; of two members calledsales.csv, only the first could be read before.tl_read_zip()andtl_read_kaggle()refuse an archive with a member whose name is an absolute path such as/x.csv, has a..component, or, on Windows, names a drive (C:x.csv). Before R 4.5.1, which tidylearn supports,unzip()extracts such members as written, so a crafted archive could plant a file anywhere the user can write, such as an.Rprofilethat runs at the next R start. From R 4.5.1unzip()drops the..components instead, so on those versions such an archive used to be read. Every..component is refused, including one that stays inside, such asa/../b.csv.tl_read_github()reads file links fromwww.github.com, links carrying a query such as?raw=true, and links using/raw/in place of/blob/. The raw-file address was built by substituting text:?raw=truehid the file extension,www.github.comwas taken for “owner/repo” shorthand, and in a repository owned by an account calledblobthe owner’s name was removed in place of the/blob/segment. A link to something other than a file, such as a/tree/folder, is refused by name, and araw.githubusercontent.comlink keeps its query, where a private file’s token lives.tl_read_kaggle()reads the dataset a Kaggle URL names, whichever tab it was copied from. The slug was the URL’s last two path segments, sohttps://www.kaggle.com/datasets/uciml/iris/datadownloaded the datasetiris/data, another owner’s, which passed validation;/codeand/versions/2did the same, and a trailing slash or a?select=query was refused with a message about something else. A competition URL is read as a competition withouttype = "competition", and contradicting it withtypeis an error.typeitself is checked: a misspelt value was treated as"dataset".tl_read_kaggle(dest = )downloads into a directory of its own and copies the download intodest. It downloaded intodestand unpacked every zip there, so an unrelated archive in the folder overwrote the user’s files, and a later call could return an earlier download’s files, re-extracted and so the newest, in place of the dataset asked for. Files from this download still replace files of the same name indest; nothing else there is read, unpacked or changed.tl_read_postgres()connects with apostgres://connection string. It passed the string to RPostgres asdsn, which libpq has no keyword for, so every connection string failed with ‘invalid connection option “dsn”’. The URL is taken apart into host, port, user, password and database name; query parameters such assslmode=requirego to libpq as connection keywords; and the named arguments fill parts the URL leaves out, which keeps a password out of it.tl_read_mysql()uses itsdbname,user,passwordandportarguments for the parts amysql://URL leaves out. With a URL they were ignored, soport = 3307alongside"mysql://host/db"connected to 3306.Database connection strings are percent-decoded, so a password written
p%40ssreaches the server asp@ss; it was sent encoded.tl_read_mysql()refuses query parameters:?ssl-mode=REQUIREDbecame part of the database name, and RMariaDB ignores arguments it does not know, so passing it on would connect without the TLS asked for. PassRMariaDB::dbConnect()arguments such asssl.cathrough...instead. The progress message fromtl_read()and thetl_sourceattribute redact a password given as?password=, as a libpq keyword, after an empty user name (postgres://:secret@host) or under an upper-case scheme, all of which were shown in the clear. The same goes for the SSL key passphrase (?sslpassword=), for the other secret-bearing keywords newer libpq versions take (oauth_client_secret,scram_client_key,scram_server_key), and for a password written in quotes (password='two words') or with spaces around=. A local path is left alone: the redaction tookC:\Users\ana@corp\data.csvforuser:password@hostand printedC:***@corp\data.csv. The “File not found” error redacts the path it names, which printed a connection string’s password in full.tl_read()refuses a URL whose scheme it has no reader for, such asftp://, with a message naming the scheme and listing the sources it reads; it reportedFile not found. Afile://URL is read as the local path it names, percent-decoded, where it was reported as not found, and afile://URL naming another machine is refused. A source with a scheme can no longer be sent to a file reader withformat:tl_read("postgres://ana:secret@host/db", format = "csv")failed with “File not found” and printed the password.bigquery://project/datasetsources are detected and read withtl_read_bigquery(), where they stopped with “Cannot detect format”.tl_read_bigquery(),tl_read_postgres()andtl_read_mysql()checkproject,dsnandquerybefore contacting a server, and the message names the argument. ANULLprojectordsnfailed with “argument is of length zero”, and anNAdsnorquerywas handed to the database driver.tl_read_bigquery()also refuses abigquery://URI with an empty project or dataset, such asbigquery:///my_dataset.tl_read_bigquery(dataset = )reaches the query as its default dataset, so unqualified table names resolve against it, as documented. It only labelled the result.
Compute and cloud
tl_compute_advisor(), and with ittl_model(compute = "auto"), handle problems past 2^31 - 1 cells. Rows times predictors was an integer product, so 1e7 rows by 250 predictors overflowed toNAand both failed with “missing value where TRUE/FALSE needed”, on the inputs the advisor exists to size.tl_compute_advisor()counts predictors from the terms the formula expands to against the data, so its estimates change. It counted the variable names on the right-hand side, so on a frame of 200 predictors plusidandy,y ~ . - idwas sized as 2 predictors andy ~ 1as 201. A fitted model is sized from its own formula the same way.tl_compute_advisor()sizes the fit tidylearn would run, so its estimates change. A runtime hyperparameter left out takes the default of the method’s fit function. It assumed 10 epochs of 128 units for"deep", wheretl_fit_deep()trains 30 epochs through layers of 32 and 16, and a hidden layer of 10 for"nn"againsttl_fit_nn()’s 5, which halves the"nn"estimate. On iris’s four predictors the"deep"estimate is 3.8 times the old one."deep"readshidden_layers;units, which the help page listed, is no tidylearn argument and is ignored. A hyperparameter the estimate reads must be a positive number:nrounds = NAfailed with “attempt to select less than one element”, and a negative value gave a negative runtime.print()on atl_compute_advisor()result, and its reasoning and notes, write large numbers out in full with thousands separators. A round value printed in scientific notation: a 100,000 MB peak read “1e+05”.tl_cloud_allow_host()drops the trailing dot of a fully qualified name."fits.example.com."was stored with the dot, which no endpoint’s host carries, so the addition matched nothing.
Documentation
?tl_modellists the arguments tidylearn takes for each method – among themdegreefor"polynomial",alpha,lambdaandcv_foldsfor the regularised methods, which were documented only on internal pages, andmds_methodandk(orndim) for choosing an MDS variant and its dimensions – names"polynomial"among the methods, and says that a logical response is a regression for every method but"logistic".The reporting vignette said every plot function returns a ggplot2 object.
plot_dendrogram(), whichplot()uses for an hclust model,tl_plot_tree()andtl_plot_nn_architecture()draw with base graphics,tl_plot_xgboost_tree()returns a DiagrammeR widget, and the dashboards andplot(type = "diagnostics")return a grid or a list of plots; the vignette now lists the exceptions. It also said its reporting code runs unchanged withmethod = "svm", for whichtl_table_importance()errors. The README claimed ggplot2 output regardless of model type and labelledplot(linear_model)as diagnostic plots, where it draws actual against predicted values. The same “tibble or ggplot2 object” claim, in the README’s Philosophy section and the getting-started vignette, now covers predictions, metrics and most plots, and getting-started counts 20 methods from 13 packages where it said 20 packages.The supervised-learning vignette counted eleven supervised methods where there are thirteen, and said regularised regression and SVM need pre-scaled inputs. glmnet and e1071 standardise internally by default, so scaling beforehand leaves their predictions unchanged. The advice now covers
"nn"alone.The unsupervised-learning vignette called “Cluster 1” the clean k-means cluster, but k-means numbers clusters arbitrarily and the vignette set no seed, so setosa came out as cluster 1, 2 or 3 depending on the random state. The vignette now sets a seed and names the setosa cluster. It also said single linkage puts almost everything in one cluster, beside output showing 98 of 150 rows; it now gives that count.
The AutoML vignette said a 30-second budget at the default five folds skips cross-validation entirely; on iris every model in that run is cross-validated. Its time-budget section now lists the gates the code applies: the forest and the advanced models need
time_budget >= 30, the PCA and cluster phases start only while more than 5 seconds and 10% of the budget remain, the advanced phase only while more than 40% remains, and a model is cross-validated only while more than 30% remains. Theglm.fitconvergence warnings it hides, counted as a dozen, came to six to eight in four runs and are now described as several. The vignette now sets a seed, so its folds and leaderboards repeat between builds.?tl_auto_mldescribes the time budget as the code applies it. It said a budget under 30 seconds skips cross-validation and fits two models; its own example,time_budget = 10on iris, fits one tree and cross-validates it.?tl_get_best_modelsays the returned model expects preprocessed data and is meant to be used throughtl_predict_pipeline();predict()on it with raw rows returns wrong values without a warning. The help page fortl_reduce_dimensions()had the title “Integration Functions: Combining Supervised and Unsupervised Learning”; it now has its own.The help pages of the twelve readers of local files –
tl_read(),tl_read_dir(),tl_read_zip(),tl_read_csv(),tl_read_tsv(),tl_read_excel(),tl_read_rds(),tl_read_rdata(),tl_read_parquet(),tl_read_json(),tl_read_db()andtl_read_sqlite()– have examples that run, on temporary files or on the sample files readr and readxl ship, each guarded on the suggested package it needs. Every line of their examples was commented out. The six readers that need a server, credentials or the network have their examples as code in\dontrun{}. Thetl_compute_advisor()example fits xgboost on two threads.The
@returnsections oftidy_pam(),tidy_dbscan(),tidy_apriori(),tidy_kmeans()andtidy_hclust()name the elements the functions return. They listedsilhouette(the elements aresilhouette_avgandsilhouette_data),core_points(the flags areclusters$is_core) and the class"tidy_rules"(it is"tidy_apriori"), and left outtidy_kmeans()’ssizes,tidy_hclust()’sdistance_method, andtidy_dbscan()’ssummary,epsandminPts.visualize_rules()’s@returnsaid onlymethod = "scatter"returns a ggplot and that other methods draw as a side effect. arulesViz returns ggplot objects for"graph","grouped"and"matrix"too; only"paracoord", which draws with grid, returns a gridvpPath. Itsrules_objdocumentation no longer lists a table of rules, which the function refuses.inst/examples/unified_workflow.Rprinted “Transfer learning model built on over 3 principal components”: it read$spec$method, which atl_transfer_learning()result does not have. It also printed “Original data: 5 features with missing values” when one of the five had any. The lines now read “Transfer learning model: forest fitted on 3 principal components” and “Original data: 5 features, 1 with missing values”, and the processed-data line counts its missing values the same way.tests/testthat/test-examples.Rchecks all three.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.The hex logo has sharp corners, in line with other R package hex stickers. The artwork is otherwise unchanged.
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
