1. 先搞清楚 caret 包到底站在建模链路的哪个位置
1.1 它统一的是接口,不是算法
很多人第一次接触caret 包,会误以为它是一个新的机器学习算法库。其实不是。caret 的全称是 Classification And REgression Training,它做的事情非常朴素:把 R 生态里几十上百个建模函数那些五花八门的调用方式、参数命名、预测返回值结构,统一成一套约定好的形态。随机森林用 formula 传参、梯度提升树要你先拼矩阵、正则化回归又是另一套系数提取方式,这些差异在真实项目里会把人折磨得够呛,caret 就是在中间加一层适配器。
这个定位决定了它的价值边界。它不会让你的模型变准,但它能让你的实验流程变得可比较、可复现、可批量。我个人的判断标准很简单:如果你一个项目只跑一个模型、一次调参就结束,用不用 caret 无所谓;但只要你要横向比较三五个算法、要反复做交叉验证、要把预处理和调参绑在一起做成流水线,caret 带来的秩序感就非常划算。
我见过不少新手在 caret 上翻车,根本原因就是把“统一接口”误解成“统一算法”。他们以为写了method = "rf"和method = "xgbTree",两个模型的行为逻辑就一致了。实际上 caret 只是在外面套了个壳,里面的树怎么长、正则怎么加、参数怎么命名,依然是各自的包说了算。搞清楚这一层,后面所有的困惑都会少一半。
1.2 train 函数的三根支柱
caret 的核心入口就一个函数:train()。它的参数看起来多,但真正决定实验设计走向的只有三块。第一块是模型公式与数据,决定你喂进去什么;第二块是trainControl,决定重采样怎么做、指标怎么算、结果留不留;第三块是调参空间,也就是 tuneGrid 或者 tuneLength,决定你在多大范围里搜索超参数。
这三块里,最容易被人轻视的是 trainControl。新手往往把注意力全放在“哪个模型更准”上,随手写个trainControl(method = "cv", number = 10)就开工。可实际上,重采样方案的选择直接决定了你后面所有结论的可信度。时间序列数据用普通 K 折交叉验证,等于让模型偷看未来;类别极度不平衡的数据用分层抽样之外的方式,某些折里可能连一个正例都没有,指标直接炸掉。
三根支柱之外的东西,比如preProcess、metric、tuneLength,都是在这三块基础上做微调。理解了主干,剩下的参数查文档就能补上。
1.3 明确边界:caret 不负责的事
踩过坑之后我总结出一条经验:先划清工具边界,再谈怎么用。caret 不做特征工程的可视化探索,不做业务层面的因果推断,也不帮你判断某个特征该不该进模型。它也不解决数据泄漏这类设计问题——虽然它的preProcess机制天然能规避一部分泄漏,但如果你自己在外面先做了标准化再喂进去,它不会提醒你。
另一个常见误解是把它当成自动机器学习。caret 的网格搜索是暴力枚举,不是贝叶斯优化,也没有任何智能剪枝。你给多大的网格,它就老老实实跑多少组合乘以多少折。一个 5 参数、每参数 4 个取值、10 折交叉验证的网格,就是 4^5 × 10 次训练,这个计算量在本地机器上可能要跑一整晚。想清楚这一点,你在设计网格时就会克制很多。
2. 环境准备与数据预处理,细节决定后面会不会翻车
2.1 安装与依赖的连带关系
caret 本身的安装很直接,但它是一个“壳包”,真正的算法实现分散在几十个后端包里。这意味着你library(caret)成功,不代表method = "xgbTree"就能跑起来。第一次调用某个 method 时报“package not found”,几乎所有人都遇到过。
install.packages("caret") library(caret) # 查看当前环境支持哪些模型 modelList <- getModelInfo() length(modelList) # 查某个 method 需要哪些依赖包 getModelInfo("xgbTree")[[1]]$library我的习惯是:确定要用哪几个 method 之后,先跑一遍getModelInfo()把依赖列出来,一次性装齐,别等到半夜网格跑到一半报错中断。另外要注意,caret 对后端包的版本有一定敏感性,尤其是 xgboost、glmnet 这类更新频繁的包。如果出现参数名对不上的诡异报错,先怀疑版本,再怀疑代码。
提示:caret 的维护节奏这几年明显放缓,社区重心转向了 tidymodels。这不影响 caret 继续可用,但遇到新版本后端包不兼容时,回退后端包版本往往比改 caret 代码更快。
2.2 预处理必须放进 train 内部
这是 caret 最值得称道的设计,也是最容易被浪费掉的设计。很多人习惯在建模前手动做中心化和标准化:
# 不推荐:先在外面处理,再进 train scaled_x <- scale(x) model <- train(x = scaled_x, y = y, method = "glmnet")问题在于,一旦你在交叉验证之前做了标准化,验证折的均值方差信息就混进了训练过程,指标会虚高。caret 的做法是把预处理写进preProcess参数,让它跟着每一次重采样在训练折内部单独拟合,然后应用到对应的验证折上。
model <- train( x = x, y = y, method = "glmnet", preProcess = c("center", "scale"), trControl = trainControl(method = "cv", number = 10) )支持的预处理项不少,常用的有center(中心化)、scale(标准化)、BoxCox(正态化变换)、YeoJohnson、pca(主成分降维)、knnImpute(K 近邻填补缺失)。要注意pca放在预处理链里,意味着模型是在降维后的空间里训练的,解释性会大打折扣,别图省事加进去。
还有一个顺序细节:多个预处理项是按向量顺序依次执行的。c("center", "scale")是先中心化再标准化,逻辑正确;写成c("scale", "center")虽然结果接近,但概念上就拧巴了。涉及knnImpute时更要小心,它内部会强制做中心化和标准化,如果你又手动加了center、scale,重复处理不会报错,但会让数值范围偏离预期。
2.3 分类标签与变量名的隐形地雷
caret 对分类标签有个硬性要求:水平名必须是合法的 R 变量名。0和1这种纯数字、带空格、带连字符的标签,会直接导致train()报错,报错信息还相当隐晦,往往只说“levels 有问题”。
# 稳妥做法:建模前统一改造成合法名 y <- factor(y, levels = c(0, 1), labels = c("neg", "pos"))变量名同理。列名里有空格、括号、中文,在 formula 接口下必然崩,在 x/y 接口下也容易在预测阶段出问题。我一般在读数据之后立刻做一次列名清洗:
names(dat) <- make.names(names(dat), unique = TRUE)make.names会把非法字符替换成点号,并保证唯一性。这一步花不了几秒钟,却能省掉后面半小时的排查。另外提醒一句,make.names遇到中文列名会转成X.开头的形式,如果确实要用中文列名,最好在建完模型后再映射回原名做展示,别在建模过程中硬扛。
3. train 参数逐个拆解:调参到底在调什么
3.1 method 的选择逻辑
method是 train 里信息量最大的参数,它同时决定了算法、可调参数集合、默认评价指标、预处理建议。选它不是“哪个准选哪个”,而是先看数据形态和业务约束。
| method 取值 | 适用场景 | 可调参数数量 | 需要额外依赖 |
|---|---|---|---|
lm/glm | 基线模型、解释性优先 | 0 | 无 |
glmnet | 高维稀疏、需要正则化 | 2(alpha、lambda) | glmnet |
rf | 中小样本、特征交互多 | 1(mtry) | randomForest |
ranger | 大样本、要快 | 2 | ranger |
xgbTree | 结构化数据主力 | 5 左右 | xgboost |
svmRadial | 中小样本、边界非线性 | 2 | kernlab |
knn | 小样本、低维 | 1(k) | 无 |
这张表里最值得说的一句经验是:先跑基线,再上复杂模型。我习惯先用method = "glm"或method = "lm"跑一遍,拿到一个 RMSE 或准确率基准。如果复杂模型比基线只好一点点,那这点提升大概率落在置信区间内,不值得为它增加部署和维护成本。很多汇报里“模型提升 2%”的结论,在交叉验证的重采样方差面前其实站不住脚。
选 method 时还要留意默认调参范围。caret 给每个 method 内置了一套默认网格,tuneLength就是在这套默认范围里等距取点。默认范围通常是原作者凭经验给的,未必适合你的数据尺度,所以正式跑之前用getModelInfo()看一眼参数范围,再决定要不要手写 tuneGrid。
3.2 重采样方案怎么定
trainControl里的method参数决定了评估策略。默认是"boot"(bootstrap 抽样),我一般会显式改成"cv"或者"repeatedcv",因为自助法的训练集有重复样本,指标解读起来不够直观。
"cv":标准 K 折,K 取 5 或 10。样本量小于 5000 时用 10 折,偏差小但方差略大;样本量大时用 5 折,省时间。"repeatedcv":重复 K 折,比如 10 折重复 3 次。适合样本量小、单次划分波动大的场景,代价是计算量翻倍。"LOOCV":留一法,样本量极小时才考虑,计算量随样本数线性增长,基本别用在超过几百行的数据上。"timeslice":时间序列专用,按时间顺序滚动切分,不会让模型看到未来。
时间序列这一点要单独强调。我见过有人拿销售预测数据直接跑 10 折交叉验证,指标漂亮得不像话,上线后全线崩盘。原因就是随机打乱后,模型在训练集里看到了“未来”的数据点。用timeslice之后指标会难看得多,但那才是真实的泛化能力。
还有一个容易被忽视的参数是classProbs。默认情况下 caret 只输出类别预测,不输出概率。如果你要做 ROC 曲线、要算 AUC、要按概率阈值做业务决策,必须显式打开:
ctrl <- trainControl( method = "repeatedcv", number = 10, repeats = 3, classProbs = TRUE, summaryFunction = twoClassSummary, savePredictions = "final" )注意summaryFunction和metric要配套。用了twoClassSummary就必须把metric = "ROC"传给 train,否则 caret 还会按默认的 Accuracy 排序,你看到的排序结果和 summaryFunction 算出来的东西对不上,很容易误判。
3.3 网格搜索、随机搜索与 tuneLength
调参空间的指定有三种方式,各有适用场景。
最省事的是tuneLength = n,让 caret 在默认范围内等距取 n 个点。适合探索阶段,快速摸清参数的大致敏感度。缺点是等距取点可能错过最优点,尤其当最优值落在边界附近时。
最可控的是tuneGrid,手写数据框,每行一组参数组合。适合你已经知道大致范围、要做精细搜索的时候。
grid <- expand.grid( nrounds = c(100, 300, 500), max_depth = c(3, 6), eta = c(0.05, 0.1), gamma = 0, colsample_bytree = 0.8, min_child_weight = 1, subsample = 0.8 ) model_xgb <- train( x = x_train, y = y_train, method = "xgbTree", trControl = ctrl, tuneGrid = grid, metric = "ROC" )这个网格是 3 × 2 × 2 × 1 × 1 × 1 × 1 = 12 组,配合 10 折重复 3 次就是 360 次训练。如果嫌慢,可以先用粗网格定位,再用细网格在最优值附近搜索,这是我自己最常用的两阶段策略。粗网格步长放大,比如 eta 用 0.01 / 0.1 / 0.3,定位到 0.1 附近之后再在 0.05 到 0.15 之间细搜。
第三种是search = "random",配合tuneLength使用,在参数空间里随机采样。参数维度高、网格组合爆炸时特别有用。经验值是随机搜索的采样次数取参数个数的 10 到 20 倍,通常就能覆盖到较优区域。
注意:
expand.grid生成的是全组合,参数一多就会指数膨胀。写网格前先心算一下行数,超过两百组就该考虑拆分成两阶段或者转随机搜索了。
3.4 自定义评价指标
业务指标和默认指标经常对不上。默认的 Accuracy 在类别不平衡数据上毫无意义——一个 95% 负例的数据集,全预测成负例也能拿 95% 的准确率。这时候就需要自己写 summaryFunction。
f1Summary <- function(data, lev = NULL, model = NULL) { # data 包含 pred 和 obs 两列,以及各类别概率列 tp <- sum(data$pred == "pos" & data$obs == "pos") fp <- sum(data$pred == "pos" & data$obs == "neg") fn <- sum(data$pred == "neg" & data$obs == "pos") precision <- tp / (tp + fp) recall <- tp / (tp + fn) f1 <- 2 * precision * recall / (precision + recall) c(F1 = f1, Precision = precision, Recall = recall) }这个函数会被 caret 在每一折的验证集上调用一次,返回的命名向量会进入最终结果的汇总表。要记住两点:一是返回的指标名会直接作为metric的候选值,名字别写错;二是函数里不要做任何依赖全局变量的计算,因为并行时每个工作进程是独立环境,全局变量可能取不到。
写自定义指标时还有个细节:data$pred在classProbs = TRUE时是类别预测,概率列名是pos和neg这种水平名。如果你在函数里用data[["pos"]]取概率,要确保水平名和你写的一致,否则会静默返回 NULL,最后指标算出来是 NaN,排查起来很费劲。
4. 特征工程与模型解释的实操路径
4.1 近零方差与高相关的处理顺序
caret 提供了一组特征筛选的辅助函数,最常用的是nearZeroVar和findCorrelation。前者找的是方差极小、几乎全是同一个值的列——这类特征对模型没有任何信息量,反而会拖慢训练、干扰重要性排序。后者找的是相关系数超过阈值的列对,帮你从高度冗余的特征里挑一个留下。
# 近零方差特征 nzv_idx <- nearZeroVar(x, saveMetrics = TRUE) x_clean <- x[, !nzv_idx$nzv] # 高相关特征 cor_mat <- cor(x_clean) high_cor_idx <- findCorrelation(cor_mat, cutoff = 0.85) x_clean <- x_clean[, -high_cor_idx]顺序上有个讲究:先做近零方差,再做相关性分析。因为近零方差列的方差接近零,它和其他列的相关系数也会趋近于零或者变成 NaN,放在相关性矩阵里只会污染结果。这个顺序我在早期没注意,导致cor()报出大量 NA,白白排查了半天。
findCorrelation的 cutoff 设多少,没有标准答案。0.85 是我用得比较多的值,0.9 偏宽松、0.75 偏激进。要提醒的是,这个函数默认是贪心地删除“平均相关性更高”的那一列,它不看哪一列和标签更相关。如果删掉的恰好是业务上最重要的特征,模型效果会明显下降。更稳的做法是结合varImp先看重要性,把重要特征保护起来再删冗余。
4.2 rfe 递归特征消除的用法与陷阱
rfe()是 caret 里的包裹式特征选择方法。它的逻辑是:先用全部特征训练,按重要性排序,踢掉最不重要的那一批,再训练,再踢,循环到指定数量。整个过程嵌套在重采样里,所以能给出每个特征子集大小对应的交叉验证表现。
ctrl_rfe <- rfeControl( functions = rfFuncs, method = "cv", number = 5, verbose = FALSE ) rfe_result <- rfe( x = x_train, y = y_train, sizes = c(5, 10, 20, 40, 60), rfeControl = ctrl_rfe ) rfe_result$optVariables这里最大的坑是计算量。sizes里每个尺寸乘以交叉验证折数,就是训练次数。5 个尺寸 × 5 折 = 25 次,每次还要额外训练一个完整模型来算重要性,实际开销会翻倍。特征数上千、样本量又大的时候,这个函数能跑一整天。
另一个坑是sizes的设置。如果你把 sizes 设成从 1 到 100 的连续序列,计算量会爆炸不说,还容易出现“最优尺寸就是最大尺寸”的无意义结果——说明你的特征本来就不冗余,用 rfe 纯属浪费。我的经验是先跑一次全特征的varImp,看看重要性的衰减曲线,一般在曲线变平的位置附近设三到五个 sizes 就够了。
还有一个隐性陷阱:rfe用的重要性度量来自rfFuncs这类函数集,不同函数集给出的重要性排序可能差异很大。树模型的重要性偏向高基数特征,线性模型的重要性受共线性影响。如果你的数据里既有连续变量又有类别变量,最好换一两个函数集交叉验证结论,别只信一套。
4.3 varImp 的解读陷阱
varImp()输出的是相对重要性,不是绝对值。它把最重要的特征缩放到 100 分,其余按比例折算。这个设计便于横向比较,但很容易被误读成“重要性占比”。
imp <- varImp(model_xgb, scale = TRUE) plot(imp, top = 15)几个必须知道的点。第一,scale = FALSE会返回原始的重要性数值,树模型是基尼增益或分裂次数,线性模型是标准化系数绝对值。原始值在跨模型比较时才更有意义。第二,树模型的重要性在特征高度相关时会互相稀释——两个完全相同的特征各分走一半重要性,看起来都不重要,实际上把其中一个删掉,另一个的分数会立刻翻倍。这也是为什么我坚持先处理相关性再看重要性。第三,分类问题里varImp可能按类别分别输出,多分类时默认取最大值或平均值,具体行为取决于模型实现,别想当然。
还有一点常被忽略:varImp只是模型内部视角,它回答的是“模型用了哪些特征”,不是“哪些特征真正影响结果”。一个特征重要性高,可能只是因为它和另一个未观测的变量相关。做业务解释时,这一步只能作为参考,不能当成因果结论。
5. 多模型比较与结果提取
5.1 resamples 的配对比较
跑完几个模型之后,怎么判断差异是不是真实存在?caret 的resamples()给了一个很实用的方案:把这几个模型在同一套重采样划分下的表现放进一个对象,做配对比较。
results <- resamples(list( GLM = model_glm, RF = model_rf, XGB = model_xgb )) summary(results) bwplot(results, metric = "ROC") diff <- diff(results) summary(diff)关键在于“同一套重采样划分”。这要求几个模型共用同一个trainControl对象,而且如果用了并行,还要保证随机种子一致,否则划分不一致,比较就失去了配对的意义。我在早期没注意这点,几个模型各自跑各自的trainControl,resamples倒是能跑,但差异检验的结论完全不可信。
diff()输出的是两两之间的差值分布。看这个结果比看平均分更有价值:如果 XGB 比 GLM 均值高 2%,但差值的置信区间跨过零,那这个提升就不能算稳。这种判断方式比单看准确率排行榜靠谱得多,也更能挡住业务方“为什么不选分数最高那个”的追问。
5.2 从 train 对象里把东西掏干净
train()返回的对象是个大列表,很多人只取$results就完事了,其实里面还有很多东西。$finalModel是用最优参数在整个训练集上重训的模型,可以直接拿去预测;$bestTune是最优参数组合;$pred保存了每一折的预测明细(前提是 trainControl 里设了savePredictions)。
best <- model_xgb$bestTune final <- model_xgb$finalModel # 用最优模型预测新数据 pred <- predict(model_xgb, newdata = x_test) prob <- predict(model_xgb, newdata = x_test, type = "prob") # 混淆矩阵 confusionMatrix(pred, y_test, positive = "pos")要提醒的是,$finalModel的结构因 method 而异。rf返回的是 randomForest 对象,xgbTree返回的是 xgb booster 列表,glmnet返回的是 cv.glmnet 对象。想深入操作得先查对应包的文档,别指望用统一的方式访问内部属性。这也是 caret 那层壳的边界所在——它统一了输入输出,没有统一内部结构。
还有一点:predict(model, newdata)会自动应用训练时的预处理。这是好事,但也意味着如果你手动对 newdata 做了预处理,会重复处理两次。我见过有人为了“保险”两边都做,结果预测分布完全对不上训练分布,排查了很久才发现是这个原因。
5.3 并行与随机种子的一致性
网格搜索天然适合并行。caret 的做法是挂一个并行后端,注册后 train 会自动分片:
library(doParallel) cl <- makePSOCKcluster(4) registerDoParallel(cl) # ... 跑 train ... stopCluster(cl) registerDoSEQ()两个坑要注意。第一,Windows 上必须用makePSOCKcluster,makeForkCluster不支持;Linux 和 macOS 两者都行。第二,跑完之后记得stopCluster和registerDoSEQ(),否则后续其他包的并行行为可能被干扰,出现莫名其妙的卡死。
随机种子的处理更微妙。并行时每个工作进程有独立的随机数状态,如果不显式指定 seeds,不同进程产生的重采样划分可能不一致,导致结果不可复现。caret 的解决方案是在 trainControl 里传 seeds:
set.seed(2024) seeds <- vector(mode = "list", length = 31) for (i in 1:30) seeds[[i]] <- sample.int(1000, 12) seeds[[31]] <- sample.int(1000, 1) ctrl <- trainControl( method = "repeatedcv", number = 10, repeats = 3, seeds = seeds )这里的长度是“总折数 + 1”,最后一个是最终模型训练的种子,前面每个元素对应一折里所有参数组合的种子。这个结构第一次写容易搞错长度,写错了会在跑到一半时报错,非常浪费时间。我的做法是先用number * repeats算出折数,再写个小循环生成,别硬编码。
6. 常见报错与排查速查
6.1 报错速查表
| 报错关键词 | 常见原因 | 处理方式 |
|---|---|---|
subscript out of bounds | tuneGrid 参数名和 method 不匹配 | 用getModelInfo()核对参数名 |
object 'X' not found | formula 接口下变量名非法 | 用make.names()清洗列名 |
levels of y相关报错 | 分类标签含数字或特殊字符 | 转成合法因子水平 |
missing values in resampled performance measures | 某折指标为 NaN | 检查是否某折无正例,改用分层抽样 |
there are some missing values | 特征含 NA 而模型不支持 | 加preProcess = "knnImpute"或先补缺 |
cannot allocate vector | 网格过大或样本过多 | 减少网格、降采样、分块处理 |
package xxx is required | 后端包未安装 | 按getModelInfo()提示安装 |
NA/NaN/Inf in foreign function call | 特征含无穷值 | 用is.finite()过滤或用preProcess处理 |
这张表里前四条覆盖了我实际遇到的八成问题。尤其第一条,参数名拼错一个字母,报错信息完全指不到具体位置,只能靠getModelInfo("method名")[[1]]$grid去比对。
6.2 性能与内存的现实约束
caret 的性能瓶颈通常不在算法本身,而在数据复制。每次重采样都要从原始数据框里切一份出来,特征数一多、折数一多,内存占用会迅速上涨。我的一般策略是:先把无关列删干净,把字符型转成因子,把能降采样的地方降采样,然后再进 train。
另外,savePredictions = "final"只保存最优参数组合的预测,比TRUE(保存全部组合)省内存得多。做模型比较、画校准曲线,用"final"就够。只有在需要分析参数敏感度、看不同组合的预测差异时,才需要TRUE。
还有一个不太直观的点:预处理里的pca和knnImpute在大数据上很慢,因为它们要在每一折内重新拟合。如果特征数上千,光是预处理就能占掉一半时间。这种场景下我会考虑先做一次粗筛把特征降到几百以内,再进 caret 流程。
6.3 结果复现的三道保险
想让别人复现你的结果,或者三个月后自己复现,需要三样东西同时固定:一是set.seed(),而且必须在调用 train 之前设;二是 trainControl 里的seeds参数,并行场景下缺它不可;三是记录后端包的版本,sessionInfo()存一份。
set.seed(2024) # ... train ... writeLines(capture.output(sessionInfo()), "session_info.txt")我吃过一次亏:同一个脚本,同一个种子,隔了一个月在另一台机器上跑,结果差了 3 个百分点。查了半天才发现是 randomForest 包升级后默认的节点分裂规则有微调。从那以后我养成了存 sessionInfo 的习惯,虽然大部分时候用不上,但真出问题时能省掉大量排查时间。
提示:caret 支持用
savePredictions = "all"配合$pred做自定义的指标计算和可视化。如果你想画 PR 曲线、做阈值扫描,这个字段比$results有用得多,别只盯着汇总表看。
7. 我个人在长期使用中沉淀的几条判断
caret 这个包我用了很多年,它最打动我的地方不是功能多,而是它把“实验纪律”固化进了代码结构。preProcess强制你在重采样内部做预处理,trainControl强制你把评估方案写清楚,resamples强制你用同一套划分做比较。这些约束在熟练之后会觉得啰嗦,但对刚上手的人来说,它们是防止犯低级错误的护栏。
如果让我给第一次用 caret 的人一句建议,那就是:先把一个小数据集、一个简单模型、一个清晰的评价指标跑通整条链路,从预处理到重采样到结果提取到最终预测,全部走一遍。这条链路打通之后,换 method、加网格、上并行都是在这个骨架上加东西,不会乱。反过来,一上来就堆五个模型、上复杂网格,报错会多到让你怀疑工具本身。
另一个我越来越重视的点是指标的业务一致性。caret 能算的指标很多,但能不能选对,取决于你对业务的理解。做风险识别时漏判的代价远高于误判,那就该盯召回率而不是准确率;做资源分配时正例太多会撑爆预算,那就该盯精确率。工具只负责算,判断得自己做。
最后一条纯经验:如果某个参数你调了很久,模型分数纹丝不动,那很可能这个参数对你的数据根本不敏感,继续调只是浪费时间。这时候该回头看特征、看样本质量、看标签定义,而不是在网格里继续加维度。我在好几个项目里都验证过这一点——把精力从调参挪到数据清洗上,收益往往大得多。