不同的质控方法、生长曲线和插补方法,在不同的批次上表现并不一样。与其凭经验选择,不如在自己的数据上检验:故意破坏或隐藏一部分数据,再看各方法能把结果恢复到什么程度。tidypptsda 为此提供了五个函数:

函数 检验什么 做法
validate_outliers() 体重异常值检测方法(逐日回溯追踪,以及 rlm、loess、stl、sd 及其组合) 在干净的体重记录中加入人为误差,看各方法找出了多少、误删了多少、ADG 偏了多少
validate_adg() 生长曲线模型(poly1/2/3、gompertz),以及曲线外推还是切线外推(extrap) 按连续若干天分块的 k 折交叉验证;隐藏测定开头或结尾若干天(模拟晚进站、早出栏),看 ADG 变化多少
validate_weight_fill() 体重填充方法(线性插值、poly1/2/3、gompertz),以及外推方式(extrap) 隐藏区间内部分有记录的日子(随机、成段、开头、结尾),重新填充,与实测日体重和 ADG 比较
validate_adfi() 日采食填充方法(loess_pop、loess_lm、linear) 隐藏部分有记录的日子(随机、成段、开头、结尾),这些日子的体重也一并隐藏,重新填充,与实测比较
validate_truncation() 整条流程:任意几套质控设置 删掉一部分猪若干天的全部访问(体重和采食),从体重清洗起重跑,与全部数据的 ADG、ADFI、FCR 比较

比较设置、做最终决定时,请以 validate_truncation() 为准。 前四个函数快,适合单独看某一步。从 1.5.0 起,它们都先删掉被隐藏日子的记录,再对剩下的记录重新清洗,被隐藏的数据不再影响其余记录的取舍:

函数 1.5.0 之前用到的被隐藏信息 现在
validate_outliers() “干净记录”由 qc 的方法判定,偏向这种方法 干净记录取所有被比较方法都保留的记录(reference = "consensus")
validate_adg()(交叉验证) 按天随机分折,留出日子的前后一天都在训练集里,衡量的更像是插值能力 按连续 block_days(默认 7)天分块再分折,训练集重新清洗
validate_adg()(截断)、validate_weight_fill() 异常值清洗(包括从末期往回的逐日追踪)在隐藏之前用全部记录做过 剩下的记录重新清洗
validate_adfi() 被隐藏的采食日仍用真实体重 传入 weight 时,这些日子的体重记录也删掉,日体重用剩下的记录重新计算

剩下的局限:它们每次只看一步,其余步骤仍用全部数据的结果(例如 validate_adfi() 的区间起止、采食记录的校正都来自全部数据)。各步骤之间的相互影响,只有 validate_truncation() 能看到。

五个函数都返回 ppt_validation 对象,$summary 是各方法的汇总比较,其余元素是每头猪的明细。结果可以用 seed 复现;数据量大时用 max_pigs 随机抽取一部分猪(几百头已经足够),用 cores 并行。

运行时先显示一行概况(猪数、方法数、场景和重复次数),然后按 [1/3]、[2/3] 编号显示每个阶段:进行中显示进度条、已完成数和预计剩余时间(ETA),完成后留下一行结果和耗时。最耗时的通常是”重新清洗”阶段:每个折、每个截断场景或每个隐藏方案都要把剩下的记录完整清洗一遍。最后打印一张汇总表,每列最好的值标成绿色并带 *,完整结果在 $summary 中。verbose = 0 时不显示任何信息。

每个参数的详细说明和更多例子,见《验证函数使用指南》。

下面用示例数据演示。FIRE 的猪进站较晚,统一使用 60–120 kg 区间。

qc_n <- qc_config()
qc_f <- qc_config(stage_range = c(60, 120))
v_n <- prep_visits(nedap, verbose = 0)
v_f <- prep_visits(fire, verbose = 0)
w_n <- clean_weight(v_n, qc_n, verbose = 0)
w_f <- clean_weight(v_f, qc_f, verbose = 0)

# 汇总表保留 3 位有效数字,方便阅读
tidy <- function(x) x$summary[, lapply(.SD, function(v) if (is.double(v)) signif(v, 3) else v)]

1. 体重异常值检测:validate_outliers()

做法

  1. 先用 qc 和 methods 中的每种方法各清洗一遍,所有方法都保留的记录视为”干净记录”(reference = "consensus",默认),由它们(不做异常值检测)算出的 ADG 视为”真值”。这样哪种方法都不占便宜;reference = "qc" 是 1.5.0 之前的做法,只用 qc 判定;
  2. 随机挑选 rate(默认 5%)的干净记录,加上或减去 size(默认 3–15 kg)的误差;
  3. 对加了误差的数据,分别用 methods 中的每种方法重新清洗、重新计算 ADG;
  4. 重复 reps 次,取平均。

指标

  • sensitivity(检出率):人为误差中被判为异常的比例,越高越好;
  • false_positive_rate(误删率):干净记录中被判为异常的比例,越低越好;
  • precision(精确率):判为异常的记录中,真正是人为误差的比例;
  • adg_bias、adg_rmse:ADG 与真值之差的平均值和均方根(g/d);adg_spearman:ADG 排名与真值的相关;
  • none 一行表示只做 weight_range 范围检查、不做异常值检测的结果,作为对照;track+rlm+loess 是默认设置(先逐日回溯追踪,再用 rlm 和 loess);track 只做追踪;其余各行都在关闭追踪的情况下只用对应的方法。
vo_n <- validate_outliers(v_n, qc_n, reps = 3, verbose = 0)
tidy(vo_n)
#>             method sensitivity false_positive_rate precision adg_bias adg_rmse adg_spearman n_pigs
#>             <char>       <num>               <num>     <num>    <num>    <num>        <num>  <num>
#> 1:            none       0.011            0.00e+00     1.000  -0.2550    1.870        0.999     29
#> 2: track+rlm+loess       0.940            5.58e-03     0.901   0.5540    2.190        0.999     29
#> 3:           track       0.681            5.08e-04     0.986   0.0806    1.260        0.999     29
#> 4:             rlm       0.885            1.94e-03     0.961   0.3430    1.650        0.999     29
#> 5:           loess       0.903            8.24e-05     0.998   0.0352    0.848        1.000     29
#> 6:       rlm+loess       0.917            1.98e-03     0.961   0.3990    1.630        0.999     29
#> 7:             stl       0.946            2.75e-03     0.949   0.1390    0.806        1.000     29
#> 8:              sd       0.599            0.00e+00     1.000  -0.3240    1.710        0.999     29
vo_f <- validate_outliers(v_f, qc_f, reps = 3, verbose = 0)
tidy(vo_f)
#>             method sensitivity false_positive_rate precision adg_bias adg_rmse adg_spearman n_pigs
#>             <char>       <num>               <num>     <num>    <num>    <num>        <num>  <num>
#> 1:            none     0.00502            0.00e+00     1.000    2.010     7.28        0.993     25
#> 2: track+rlm+loess     0.91800            4.93e-03     0.908    2.280     9.66        0.999     25
#> 3:           track     0.66900            4.44e-05     0.999    1.070     4.29        0.999     25
#> 4:             rlm     0.86800            2.53e-03     0.948    1.750     7.25        0.999     25
#> 5:           loess     0.89900            4.00e-04     0.992    0.257     2.58        1.000     25
#> 6:       rlm+loess     0.90800            2.62e-03     0.949    1.750     7.09        0.999     25
#> 7:             stl     0.88800            1.69e-03     0.965    1.020     3.64        0.999     25
#> 8:              sd     0.57300            0.00e+00     1.000    0.391     3.32        0.999     25

实际的称重误差多数是偏低的(猪只有前腿站在秤上),可以用 direction = "down" 单独检验:

tidy(validate_outliers(v_n, qc_n, reps = 3, direction = "down", verbose = 0))
#>             method sensitivity false_positive_rate precision adg_bias adg_rmse adg_spearman n_pigs
#>             <char>       <num>               <num>     <num>    <num>    <num>        <num>  <num>
#> 1:            none      0.0103            0.000000     1.000   0.1460     2.69        0.999     29
#> 2: track+rlm+loess      0.9370            0.005310     0.904   0.3910     2.06        0.999     29
#> 3:           track      0.6670            0.000480     0.987  -0.7710     1.53        1.000     29
#> 4:             rlm      0.8740            0.002320     0.953   0.3840     1.96        1.000     29
#> 5:           loess      0.9050            0.000261     0.995   0.0129     1.07        1.000     29
#> 6:       rlm+loess      0.9150            0.002400     0.953   0.4010     1.91        0.999     29
#> 7:             stl      0.9350            0.002400     0.954  -0.0431     0.92        0.999     29
#> 8:              sd      0.5580            0.000000     1.000  -0.1400     1.40        1.000     29

怎么看

  • 默认的 track+rlm+loess 检出率最高之一(3–15 kg 的人为误差找出九成以上),误删的好记录约 0.5%,是各方法中最多的:它把三种方法判为异常的记录都删掉。只做追踪(track)几乎不误删,但只能找出约 7 成:小于追踪容差(8%,至少 2 kg)的误差会被保留。
  • 检出率与误删率此消彼长。stl 检出率与默认相当,误删约为默认的一半;sd 几乎不误删,但只能找出六成左右;loess 单独使用时误删很少、检出率也有九成,在示例数据上 ADG 误差最小。
  • ADG 对随机分布的误差并不敏感。一头猪有几百到上千条体重记录,零散的误差对回归斜率的影响很小,所以即使完全不做异常值检测(none),ADG 的误差也只有几 g/d。各方法 ADG 的差别主要来自它们误删了哪些好记录:例如 FIRE 上,rlm 会删掉一部分偏离二次曲线、但其实正常的记录,ADG 误差约 7 g/d,loess 不到 3 g/d。
  • 真正会明显影响 ADG 的是集中出现的误差,例如进站初期几头小猪同时上秤,或秤在几天内持续漂移。这正是逐日回溯追踪要处理的情况,人为加入的随机误差检验不出它的作用,请结合 plot_adg() 的图逐站检查。

注意:“干净记录”是各方法的共识,并非完全没有误差:所有方法都漏掉的真实误差,仍留在干净记录里。$settings$n_clean 是干净记录的条数。

2. 生长曲线:validate_adg()

生长曲线决定两件事:区间的起止时间,以及区间两端(首个有记录日之前、末个有记录日之后)的日体重。这里比较的是最终的日增重(adg,补齐后日体重的回归斜率)。

做法

  • 分块 k 折交叉验证:把每头猪的日子按连续 block_days 天(默认 7 天)切成块,再把块随机分成 k 组(默认 5 组),轮流留出一组,用其余各组拟合曲线,预测留出日子的体重。cv_rmse、cv_mae 是预测误差(kg)在各头猪之间的中位数,反映曲线描述数据的能力;cv_share_over 是误差超过 cv_limit(默认 10 kg)的猪的比例。用中位数是因为灵活的曲线(如 poly3)在个别猪身上可能外推出离谱的值(上万公斤),一两头这样的猪就会把平均值拉得面目全非;这类风险单独看 cv_share_over(1.6.1 之前用的是平均值)。按块留出,留出日子的邻近几天也不在训练集里,更接近真实的成段缺失;block_days = 1 是 1.5.0 之前按单天分折的做法,误差会明显偏小。
  • 截断检验:隐藏测定开头(late,模拟晚进站)或结尾(early,模拟早出栏)的 trim_days 天(默认 10 天和 20 天),重新计算 ADG,与同一模型用全部数据算出的 ADG 比较。bias_*、rmse_*(g/d)和 spearman_* 反映曲线外推的稳定性,这正是测定期没有覆盖整个体重区间时最关心的问题。

两种检验都先删掉被隐藏日子的全部体重记录,再用 qc 的设置重新清洗剩下的记录。

va_n <- validate_adg(w_n, verbose = 0)
tidy(va_n)
#>             model cv_rmse cv_mae cv_share_over bias_late10 rmse_late10 spearman_late10 n_late10
#>            <char>   <num>  <num>         <num>       <num>       <num>           <num>    <int>
#> 1:    poly1+start    1.76   1.41             0        11.4        23.6           0.960       29
#> 2:    poly2+start    1.75   1.40             0        24.0        31.3           0.969       29
#> 3:    poly3+start    1.61   1.26             0        26.1        37.2           0.959       29
#> 4: gompertz+start    1.54   1.21             0        17.4        26.8           0.963       29
#>    bias_early10 rmse_early10 spearman_early10 n_early10 bias_late20 rmse_late20 spearman_late20
#>           <num>        <num>            <num>     <int>       <num>       <num>           <num>
#> 1:         4.30         21.5            0.997        29       -5.10        29.0           0.939
#> 2:         7.43         23.0            0.996        29        9.87        28.2           0.947
#> 3:         4.83         23.9            0.996        29       17.00        36.2           0.915
#> 4:         5.70         22.5            0.999        29        5.83        26.8           0.937
#>    n_late20 bias_early20 rmse_early20 spearman_early20 n_early20
#>       <int>        <num>        <num>            <num>     <int>
#> 1:       29        0.236         51.9            0.971        29
#> 2:       29       14.100         49.9            0.976        29
#> 3:       29       -3.190         60.1            0.933        29
#> 4:       29       -0.114         52.0            0.981        29
va_f <- validate_adg(w_f, qc = qc_f, verbose = 0)
tidy(va_f)
#>             model cv_rmse cv_mae cv_share_over bias_late10 rmse_late10 spearman_late10 n_late10
#>            <char>   <num>  <num>         <num>       <num>       <num>           <num>    <int>
#> 1:    poly1+start    2.01   1.63             0       -16.7        78.4           0.796       25
#> 2:    poly2+start    1.90   1.54             0       -49.1       109.0           0.638       25
#> 3:    poly3+start    1.83   1.47             0       -72.6       126.0           0.508       25
#> 4: gompertz+start    1.89   1.53             0       -48.2       108.0           0.657       25
#>    bias_early10 rmse_early10 spearman_early10 n_early10 bias_late20 rmse_late20 spearman_late20
#>           <num>        <num>            <num>     <int>       <num>       <num>           <num>
#> 1:       -17.00         40.2            0.963        25         144         167           0.812
#> 2:        -6.77         30.7            0.938        25         147         184           0.811
#> 3:        17.10         44.3            0.856        25         134         170           0.795
#> 4:        -9.18         40.6            0.937        25         147         184           0.814
#>    n_late20 bias_early20 rmse_early20 spearman_early20 n_early20
#>       <int>        <num>        <num>            <num>     <int>
#> 1:       24        -66.2         90.6            0.891        25
#> 2:       24        -93.3        129.0            0.762        25
#> 3:       25         34.5        116.0            0.592        25
#> 4:       24        -91.0        124.0            0.802        25

怎么看

  • 交叉验证偏向灵活的模型,但差别很小:按 7 天分块时,NEDAP 上 gompertz 和 poly3 的误差中位数最小(1.54、1.61 kg,poly2 1.75 kg),FIRE 上 poly3 最小(1.83 kg,poly2 1.90 kg),各模型只差零点几公斤;按单天分折时(block_days = 1)误差全面更小(NEDAP gompertz 1.39 kg、poly2 1.50 kg),因为留出的日子两边都有记录。灵活模型的风险不在中位数上,而在少数猪身上:留出一整段时,poly3 可能外推出离谱的值,这时看 cv_share_over。示例数据中各模型都没有误差超过 10 kg 的猪。
  • 截断检验的结论却常常相反:灵活的模型外推往往不稳定。区间中间有实测时,曲线只影响起止时间,ADG 对曲线形式不敏感;隐藏开头或结尾后,那一段要靠曲线填充,差别就出来了。
  • 没有一种模型在所有情形下都最好。在示例数据上,NEDAP 隐藏开头 20 天时 gompertz 最稳定(约 27 g/d),poly1、poly2 约 28–29 g/d,poly3 最差(约 36 g/d);FIRE 的猪进站时已在区间下限附近,隐藏开头 10 天时 poly1 的误差约 78 g/d,poly2 和 gompertz 约 110 g/d,poly3 约 126 g/d。
  • 选择模型时,先看本批次的猪是否覆盖了整个体重区间(calc_adg() 结果中的 adg_type、n_curve_days):大多数猪都是 observed 时,选哪种曲线影响很小;靠曲线填充的天数多时,应优先看截断检验,FIRE 这类晚进站的批次可考虑 curve_model = "poly1"。

曲线外推还是切线外推

extrap = c("curve", "tangent", "start") 同时比较几种外推方式(curve_extrap):记录范围内都用同一条曲线,范围之外切线外推改为直线,斜率取首、末 14 天记录的稳健斜率;"start"(默认)只在首个记录之前用直线。不是 "curve" 的模型标为 poly2+tangent 等。

va_t <- validate_adg(w_n, models = c("poly1", "poly2"), extrap = c("curve", "start", "tangent"),
                     trim_days = c(10, 20, 30), verbose = 0)
tidy(va_t)[, c("model", "cv_rmse", grep("^rmse_", names(va_t$summary), value = TRUE)), with = FALSE]
#> Key: <model>
#>            model cv_rmse rmse_late10 rmse_early10 rmse_late20 rmse_early20 rmse_late30 rmse_early30
#>           <char>   <num>       <num>        <num>       <num>        <num>       <num>        <num>
#> 1:         poly1    1.83        27.0         21.5        51.2         51.9        82.6         64.0
#> 2:   poly1+start    1.76        23.6         21.5        29.0         51.9        84.5         64.0
#> 3: poly1+tangent    1.73        23.5         21.0        28.9         51.3        84.4         66.2
#> 4:         poly2    1.85        33.9         23.0        44.8         49.9        69.2         75.0
#> 5:   poly2+start    1.75        31.3         23.0        28.2         49.9        86.8         74.8
#> 6: poly2+tangent    1.72        31.4         22.0        28.4         50.0        86.9         64.5

在示例 NEDAP 数据上,开头缺 10–20 天时,开头切线明显改善 ADG(poly2 开头缺 20 天:45 → 28 g/d);开头缺 30 天时反而变差(69 → 87 g/d),外推太远时直线不如曲线。末端切线在结尾缺 30 天时改善 poly2(75 → 65 g/d)。但末端的采食是按外推的体重填充的,从整条流程看,末端用切线会让 FCR 排名变差(见第 5 节),所以默认 "start"。分块交叉验证中,含首末两块的那几折也要外推,所以外推方式对 cv_rmse 也有一些影响。

3. 体重填充:validate_weight_fill()

做法

对每头猪,先用全部记录算出区间和 ADG(作为参照),再按 scenarios 隐藏区间内一部分有记录的日子(场景的写法同 validate_adfi()),删掉这些日子的全部体重记录,对剩下的记录重新清洗,然后用 methods 中的每种方法重新填充、重新计算 ADG:

  • linear:calc_adg() 的做法,两个有记录的日子之间线性插值,首尾用 curve_model(默认 poly2);
  • poly1、poly2、poly3、gompertz:所有缺失的日子都用该生长曲线填充。

指标:daily_rmse、daily_mae、daily_bias 是被隐藏日子的填充体重与实测日体重之差(kg);adg_bias、adg_rmse(g/d)和 adg_spearman 是 ADG 与参照 ADG 的比较。

sc_w <- c("random30", "blocks3", "blocks6", "late10", "late20", "early15")
vw_n <- validate_weight_fill(w_n, scenarios = sc_w, reps = 2, verbose = 0)
tidy(vw_n)
#>     scenario         method daily_rmse daily_mae daily_bias adg_bias adg_rmse adg_spearman n_pigs
#>       <char>         <char>      <num>     <num>      <num>    <num>    <num>        <num>  <int>
#>  1: random30   linear+start      0.835     0.601    -0.0632    0.311     6.06        0.996     29
#>  2: random30    poly1+start      1.620     1.320     0.2220   -5.190    12.60        0.983     29
#>  3: random30    poly2+start      1.440     1.160     0.1090   -5.620    10.90        0.993     29
#>  4: random30    poly3+start      1.170     0.927    -0.0385   -6.590    11.80        0.993     29
#>  5: random30 gompertz+start      1.190     0.949    -0.0290   -6.540     9.89        0.994     29
#>  6:  blocks3   linear+start      1.090     0.833    -0.0878   -0.762    15.30        0.995     29
#>  7:  blocks3    poly1+start      1.770     1.400     0.2070   -5.480    12.40        0.984     29
#>  8:  blocks3    poly2+start      1.530     1.230     0.0291   -6.530    15.50        0.991     29
#>  9:  blocks3    poly3+start      1.330     1.060    -0.2160   -5.910    20.30        0.985     29
#> 10:  blocks3 gompertz+start      1.270     1.020    -0.1970   -7.310    12.40        0.990     29
#> 11:  blocks6   linear+start      2.920     1.080     0.2590   13.700    90.60        0.878     29
#> 12:  blocks6    poly1+start      1.760     1.420     0.3570   -0.732    44.60        0.945     29
#> 13:  blocks6    poly2+start      3.120     1.510     0.4010    5.220    92.20        0.865     29
#> 14:  blocks6    poly3+start      1.380     1.080    -0.0393    4.040    55.50        0.954     29
#> 15:  blocks6 gompertz+start      2.990     1.300     0.1780    9.770    92.20        0.872     29
#> 16:   late10   linear+start      1.970     1.430    -0.9670   10.200    21.20        0.980     29
#> 17:   late10    poly1+start      2.130     1.680    -0.2040    4.490    20.40        0.972     29
#> 18:   late10    poly2+start      2.310     1.870    -0.7500    8.740    23.80        0.979     29
#> 19:   late10    poly3+start      2.010     1.600    -0.6590    3.990    23.70        0.979     29
#> 20:   late10 gompertz+start      1.650     1.260    -0.3170    0.113    16.60        0.983     29
#> 21:   late20   linear+start      2.430     1.670     0.3530   -6.770    50.40        0.922     29
#> 22:   late20    poly1+start      3.180     2.380     1.3700  -19.400    52.30        0.913     29
#> 23:   late20    poly2+start      2.700     2.120     0.7580  -10.900    51.70        0.913     29
#> 24:   late20    poly3+start      2.240     1.670     0.4390  -12.100    53.40        0.927     29
#> 25:   late20 gompertz+start      2.240     1.590     0.5820  -13.500    46.00        0.935     29
#> 26:  early15   linear+start      1.540     1.240     0.2860   11.900    44.20        0.986     29
#> 27:  early15    poly1+start      1.610     1.310    -0.3640    4.710    43.80        0.985     29
#> 28:  early15    poly2+start      1.520     1.250    -0.1000    7.430    43.50        0.985     29
#> 29:  early15    poly3+start      1.660     1.310     0.5430    7.530    46.10        0.986     29
#> 30:  early15 gompertz+start      1.480     1.200     0.3760    8.370    43.30        0.989     29
#>     scenario         method daily_rmse daily_mae daily_bias adg_bias adg_rmse adg_spearman n_pigs
#>       <char>         <char>      <num>     <num>      <num>    <num>    <num>        <num>  <int>
vw_f <- validate_weight_fill(w_f, scenarios = sc_w, reps = 2, verbose = 0)
tidy(vw_f)
#>     scenario         method daily_rmse daily_mae daily_bias adg_bias adg_rmse adg_spearman n_pigs
#>       <char>         <char>      <num>     <num>      <num>    <num>    <num>        <num>  <int>
#>  1: random30   linear+start      0.934     0.709    -0.0762   -2.100     11.0        0.987     25
#>  2: random30    poly1+start      1.780     1.380     0.1630   -4.210     43.7        0.971     25
#>  3: random30    poly2+start      1.450     1.150    -0.1260    3.260     13.6        0.984     25
#>  4: random30    poly3+start      1.310     1.030    -0.0923    4.550     15.7        0.976     25
#>  5: random30 gompertz+start      1.460     1.170    -0.1440    0.618     17.3        0.981     25
#>  6:  blocks3   linear+start      1.350     1.010    -0.0542    0.482     20.7        0.981     25
#>  7:  blocks3    poly1+start      2.080     1.640     0.1780    0.549     49.0        0.942     25
#>  8:  blocks3    poly2+start      1.750     1.370    -0.1620    7.170     27.5        0.963     25
#>  9:  blocks3    poly3+start      1.760     1.360    -0.0955    8.470     27.6        0.947     25
#> 10:  blocks3 gompertz+start      1.770     1.390    -0.2130    4.850     31.2        0.962     25
#> 11:  blocks6   linear+start      1.790     1.250     0.2270  -10.400     77.0        0.889     25
#> 12:  blocks6    poly1+start      2.180     1.710     0.6350   -2.920     81.1        0.906     25
#> 13:  blocks6    poly2+start      2.080     1.600     0.2120   -1.880     80.5        0.891     25
#> 14:  blocks6    poly3+start      2.280     1.600     0.2310   15.400     60.8        0.845     25
#> 15:  blocks6 gompertz+start      2.130     1.650     0.1940   -3.950     83.5        0.899     25
#> 16:   late10   linear+start      1.920     1.440     0.5200  -11.100     37.1        0.879     25
#> 17:   late10    poly1+start      1.790     1.430    -0.5770    3.070     47.3        0.940     25
#> 18:   late10    poly2+start      2.240     1.810     0.2340   -6.590     42.5        0.875     25
#> 19:   late10    poly3+start      2.270     1.810     0.4540   -9.800     46.2        0.871     25
#> 20:   late10 gompertz+start      2.240     1.830     0.2360   -8.750     43.5        0.886     25
#> 21:   late20   linear+start      1.940     1.530    -0.6190    7.130     57.9        0.845     25
#> 22:   late20    poly1+start      2.480     2.020    -0.9930   19.400     75.6        0.856     25
#> 23:   late20    poly2+start      2.260     1.810    -0.9040   16.500     69.4        0.795     25
#> 24:   late20    poly3+start      2.560     1.910    -0.5030    2.770     92.5        0.742     25
#> 25:   late20 gompertz+start      2.310     1.860    -0.9750   16.400     71.1        0.802     25
#> 26:  early15   linear+start      2.300     1.660    -0.4740  -15.300     60.2        0.930     24
#> 27:  early15    poly1+start      2.450     1.970    -0.5780  -22.500     62.6        0.953     24
#> 28:  early15    poly2+start      2.330     1.720    -0.4590  -15.100     60.1        0.943     24
#> 29:  early15    poly3+start      2.970     2.450    -0.3700   -2.360     50.7        0.829     24
#> 30:  early15 gompertz+start      2.280     1.640    -0.2750   -8.710     50.8        0.940     24
#>     scenario         method daily_rmse daily_mae daily_bias adg_bias adg_rmse adg_spearman n_pigs
#>       <char>         <char>      <num>     <num>      <num>    <num>    <num>        <num>  <int>

怎么看

  • 区间中间短缺时,线性插值明显好于生长曲线:随机或缺 3 段时,线性插值的日体重误差约 0.8–1.4 kg,生长曲线约 1.2–2.1 kg。原因是前后两天的实测体重已经包含了这头猪当时的生长状态,而曲线只描述整体趋势。
  • 缺得多时(blocks6,约三成的日子),个别猪会出大错:NEDAP 上一头猪被隐藏的日子紧挨着最后几天记录,重新清洗时,从末期往回的逐日追踪在三周的空档之后接不上,把最后两天的记录也删掉了,末端只能靠曲线外推,ADG 偏高约 670 g/d,拉高了 linear 的 adg_rmse。真实缺失时也会发生同样的事,这正是不泄露的检验才看得到的。
  • 隐藏开头时,区间起点之前往往还有区间下限以下的记录,线性插值仍能在两端实测之间插值,日体重误差与曲线相近或更小。
  • 隐藏结尾时(early15),末个有记录日之后没有实测,各方法都只能靠曲线,结果接近;ADG 的误差在 40–60 g/d,比中间缺失大得多。
  • 结论与采食插补相同:保证区间两端有记录,比选择填充方法更重要。

4. 采食插补:validate_adfi()

做法

只使用区间内有记录日子比例不低于 min_coverage(默认 0.8)的猪。对每头猪,按 scenarios 隐藏一部分有记录的日子:

  • random30:随机隐藏 30% 的日子;
  • blocks3:隐藏 3 段连续的日子,每段 3–10 天;
  • late20:隐藏区间开头的 20 天(晚进站);
  • early15:隐藏区间结尾的 15 天(早出栏)。

数字可以改,例如 random50、blocks6、late10。然后用 methods 中的每种方法(默认 loess_pop、loess_lm 和 linear)重新插补,在原本有记录的日子上计算 ADFI,与实测的 ADFI 比较。

插补要用到日体重。传入 weight(clean_weight() 的结果)时,被隐藏日子的体重记录也一并删掉,剩下的记录重新清洗、重新算日体重,和这头猪真的没进站时一样;不传时沿用全部数据的日体重(1.5.0 之前的做法,会给出警告)。

loess_pop 的群体曲线对每头猪都用留一法拟合:只用其他猪的数据,被隐藏的日子不会通过曲线”泄露”回来。

指标:mean_abs_error_pct、p95_abs_error_pct 是个体 ADFI 误差绝对值的平均和 95% 分位数(占实测 ADFI 的百分比);bias_pct 是平均偏差;spearman 是 ADFI 排名与实测的相关;daily_rmse 是被隐藏日子的日采食误差(g/d)。

f_n <- calc_adfi(v_n, calc_adg(w_n, verbose = 0), verbose = 0)
sc <- c("random30", "random50", "blocks3", "blocks6", "late10", "late20", "early15")
vf_n <- validate_adfi(f_n, w_n, scenarios = sc, verbose = 0)
tidy(vf_n)
#>     scenario    method mean_abs_error_pct p95_abs_error_pct bias_pct spearman daily_rmse n_pigs
#>       <char>    <char>              <num>             <num>    <num>    <num>      <num>  <int>
#>  1: random30 loess_pop              1.100              2.55  0.01770    0.989        455     29
#>  2: random30  loess_lm              1.090              2.52  0.06180    0.990        457     29
#>  3: random30    linear              1.010              2.42  0.07510    0.989        507     29
#>  4: random50 loess_pop              1.810              4.27 -0.10200    0.980        466     29
#>  5: random50  loess_lm              1.810              4.31 -0.00435    0.982        467     29
#>  6: random50    linear              1.740              4.39  0.10600    0.977        514     29
#>  7:  blocks3 loess_pop              1.290              3.08  0.00254    0.987        477     29
#>  8:  blocks3  loess_lm              1.360              3.26 -0.34500    0.981        483     29
#>  9:  blocks3    linear              1.610              4.84 -0.21300    0.973        533     29
#> 10:  blocks6 loess_pop              1.940              5.48 -0.51600    0.976        484     29
#> 11:  blocks6  loess_lm              2.390              6.05 -0.88700    0.959        508     29
#> 12:  blocks6    linear              2.420              6.80 -0.35700    0.953        529     29
#> 13:   late10 loess_pop              0.724              1.75 -0.41000    0.988        313     29
#> 14:   late10  loess_lm              1.540              2.55  1.31000    0.990        498     29
#> 15:   late10    linear              1.540              2.55  1.31000    0.990        498     29
#> 16:   late20 loess_pop              1.340              3.50 -0.29700    0.980        321     29
#> 17:   late20  loess_lm              4.490              7.06  4.26000    0.954        679     29
#> 18:   late20    linear              4.490              7.06  4.26000    0.954        679     29
#> 19:  early15 loess_pop              3.700              8.51  3.39000    0.969        863     29
#> 20:  early15  loess_lm              3.700              8.51  3.39000    0.969        863     29
#> 21:  early15    linear              3.700              8.51  3.39000    0.969        863     29
#>     scenario    method mean_abs_error_pct p95_abs_error_pct bias_pct spearman daily_rmse n_pigs
#>       <char>    <char>              <num>             <num>    <num>    <num>      <num>  <int>
f_f <- calc_adfi(v_f, calc_adg(w_f, verbose = 0), verbose = 0)
vf_f <- validate_adfi(f_f, w_f, scenarios = sc, verbose = 0)
tidy(vf_f)
#>     scenario    method mean_abs_error_pct p95_abs_error_pct bias_pct spearman daily_rmse n_pigs
#>       <char>    <char>              <num>             <num>    <num>    <num>      <num>  <int>
#>  1: random30 loess_pop               1.46              3.81    0.482    0.921        637     23
#>  2: random30  loess_lm               1.48              3.70    0.497    0.924        633     23
#>  3: random30    linear               1.53              4.23    0.246    0.919        683     23
#>  4: random50 loess_pop               2.04              4.92    0.369    0.918        666     23
#>  5: random50  loess_lm               2.02              5.09    0.344    0.916        670     23
#>  6: random50    linear               2.22              6.41   -0.155    0.910        700     23
#>  7:  blocks3 loess_pop               2.22              5.43   -0.337    0.886        647     23
#>  8:  blocks3  loess_lm               2.39              6.22   -0.487    0.861        668     23
#>  9:  blocks3    linear               2.79              8.48    0.197    0.839        747     23
#> 10:  blocks6 loess_pop               3.30              7.79   -0.583    0.772        683     23
#> 11:  blocks6  loess_lm               4.71              9.17   -0.978    0.693        760     23
#> 12:  blocks6    linear               5.29             13.20   -1.070    0.691        833     23
#> 13:   late10 loess_pop               1.56              3.18   -0.429    0.918        548     23
#> 14:   late10  loess_lm               2.14              7.47   -0.952    0.883        656     23
#> 15:   late10    linear               2.14              7.47   -0.952    0.883        656     23
#> 16:   late20 loess_pop               2.55              6.86   -0.743    0.862        562     23
#> 17:   late20  loess_lm               6.95             12.20   -2.380    0.443        820     23
#> 18:   late20    linear               6.95             12.20   -2.380    0.443        820     23
#> 19:  early15 loess_pop               4.00              6.88   -1.280    0.740        857     22
#> 20:  early15  loess_lm               4.00              6.88   -1.280    0.740        857     22
#> 21:  early15    linear               4.00              6.88   -1.280    0.740        857     22
#>     scenario    method mean_abs_error_pct p95_abs_error_pct bias_pct spearman daily_rmse n_pigs
#>       <char>    <char>              <num>             <num>    <num>    <num>      <num>  <int>

怎么看

  • 随机缺失影响很小:即使随机缺一半,个体 ADFI 的误差也只有百分之几。
  • 开头缺失时,loess_pop 明显更好:NEDAP 开头缺 20 天时,误差从 4.5% 降到 1.3%,偏差从 +4.3% 降到约 0;FIRE 开头缺 20 天时,误差从 7.0% 降到 2.6%,排名相关从 0.44 升到 0.86。晚进站的猪只有后期数据,单猪回归的直线往前推时,前期采食会被高估。
  • 中间成段缺失时,loess_pop 也略好:FIRE 缺 6 段时,误差从 4.7%(loess_lm)降到 3.3%。
  • 结尾缺失时三种方法相同:末个记录之后,三种方法都用这头猪自己的回归。
  • 体重也重新计算后,示例数据上的误差只变化了零点几个百分点(例如 FIRE 开头缺 20 天,loess_lm 6.8% → 7.0%):这一步对日体重并不敏感。区间起止和各步骤之间的相互影响,见下一节的整条流程检验。
  • 无论哪种方法,缺的天数越多误差越大:保证区间两端有记录,仍然比选择插补方法更重要。

这和《数据准备》中的结论一致:保证测定期两端有记录,比选择插补方法更重要。这也是 calc_fcr() 把两端外推超过 15 天的猪标记为低可信度的原因。

5. 整条流程:validate_truncation()

做法

  1. 先用每套设置跑一遍全部数据,作为参照;
  2. 选出测定区间被记录完整覆盖的猪(adg_type == "observed"),随机分成两组;
  3. 删掉第一组猪在区间开头(late)、结尾(early)或中间(gap)若干天的全部访问,体重和采食都删;第二组保持完整;从 clean_weight() 起重跑整条流程。再换第二组截断,第一组完整。这样每头猪在每次重复中恰好被截断一次,而群体曲线和校正模型始终能看到完整区间的数据,和实际批次中”只有部分猪晚进站或早出栏”的情况一样;
  4. 比较这些猪截断后与全部数据的 ADG、ADFI、FCR;
  5. 重复 reps 次,每次重新随机分组。

被删掉的日子不会以任何方式参与计算,所以结果反映的是”一头猪真的缺了这些天”时会发生什么。

指标

每个设置、场景和性状(adg、adfi、fcr)一行,取各次重复的平均,_sd 结尾的列是重复之间的标准差:

指标 含义 怎么看
bias 截断后 − 全部数据的平均差 越接近 0 越好。偏差比误差更要紧:实际批次中只有部分猪缺数据,偏差会让这些猪在排名中系统性地占便宜或吃亏
rmse、mae_pct 均方根误差、平均相对误差(%) 越小越好
pearson、spearman 与全部数据结果的相关、排名相关 越接近 1 越好
slope 截断后对全部数据的回归斜率 小于 1:个体差异被压缩,大于 1:被放大;越接近 1 越好,对育种值估计很重要
top_agree 全部数据下最好的 top(默认 20%)的猪,截断后仍在最好之列的比例 越高越好;猪少时很粗(20 头猪只看 4 头)
n、n_lost 有结果的猪数;截断后失去结果的猪数 截断太多时,猪会因测定期太短等原因被剔除
vt <- validate_truncation(
  v_n,
  settings = list(default = qc_config(),
                  curve   = qc_config(curve_extrap = "curve"),
                  lm      = qc_config(impute_method = "loess_lm", curve_extrap = "curve")),
  scenarios = c("late20", "early20", "gap10"), reps = 2, verbose = 0
)
vt$summary[trait == "fcr", .(setting, scenario, mae_pct = signif(mae_pct, 3),
                             spearman = signif(spearman, 3), slope = signif(slope, 3),
                             top_agree, n, n_lost)]
#>    setting scenario mae_pct spearman slope top_agree     n n_lost
#>     <char>   <char>   <num>    <num> <num>     <num> <num>  <num>
#> 1: default   late20   1.800    0.923 1.010      0.75    21      0
#> 2:   curve   late20   3.380    0.771 0.853      0.75    21      0
#> 3:      lm   late20   4.570    0.804 1.100      0.75    21      0
#> 4: default  early20   2.760    0.947 1.050      0.75    21      0
#> 5:   curve  early20   2.760    0.947 1.050      0.75    21      0
#> 6:      lm  early20   2.760    0.947 1.050      0.75    21      0
#> 7: default    gap10   0.696    0.988 0.963      1.00    21      0
#> 8:   curve    gap10   0.699    0.988 0.963      1.00    21      0
#> 9:      lm    gap10   1.440    0.979 0.955      1.00    21      0

default 是现在的默认设置(impute_method = "loess_pop",curve_extrap = "start"),curve 是两端都沿曲线外推,lm 是 1.3.0 之前的做法(采食都用单猪回归,体重两端都沿曲线外推)。在示例 NEDAP 数据上,开头缺 20 天时,默认设置的 FCR 排名相关约 0.92,另两种约 0.77–0.80。

在 457 头猪上的结果

在一个 48 个站、457 头猪(测定区间被完整覆盖)的 NEDAP 批次上,每个场景重复 2 次:

场景 指标 单猪回归 群体曲线 默认(群体曲线 + 开头切线 + 末端单猪回归)
开头缺 20 天 ADFI 误差 7.50% 5.87% 4.90%
FCR 排名相关 0.804 0.815 0.856
中间缺 20 天 ADFI 误差 2.88% 2.20% 2.31%
结尾缺 20 天 ADFI 偏差 +23 g/d −43 g/d +22 g/d
FCR 误差 4.51% 5.82% 4.50%
结尾缺 30 天 ADFI 偏差 −28 g/d −129 g/d −29 g/d
FCR 误差 6.65% 9.31% 6.65%
  • 群体曲线在开头和中间明显更好;在末端,它的平均误差与单猪回归相近,但系统性地低估采食,让早出栏的猪显得更省料,所以末端用单猪回归。
  • 切线外推在开头效果明显;末端用切线时 ADG 更准,但结尾缺 30 天时 FCR 前 20% 一致率从 0.70 降到 0.44,所以只在开头用。
  • 开头或结尾缺 30 天时,所有设置的 FCR 排名相关都只有 0.52–0.80:缺得太多,什么方法都救不回来,这类猪应保持低可信度标记(max_edge_extrap_days)。

重复几次

每次重复都截断全部合格的猪,所以:

  • 猪多的批次(几百头),每次已有两百多头猪参与比较,重复 1–2 次的结果几乎完全一样(上表各指标的标准差都接近 0);
  • 猪少的批次(例如示例数据的二三十头),结果会随分组变化,建议 reps = 5 到 10,看 _sd 列判断差别是否可信。

整条流程每重跑一次的时间与批次大小成正比:示例数据约 1 秒,757 头猪约 40 秒。可以用 cores 并行。

6. 在实际数据上使用

大批次数据建议抽样并行运行:

visits <- prep_visits(read_station("数据/"))
w <- clean_weight(visits, cores = 4)
a <- calc_adg(w, cores = 4)
f <- calc_adfi(visits, a, cores = 4)

vo <- validate_outliers(visits, max_pigs = 300, cores = 4)
va <- validate_adg(w, max_pigs = 300, cores = 4)
vw <- validate_weight_fill(w, max_pigs = 300, cores = 4)
vf <- validate_adfi(f, w, max_pigs = 300, cores = 4)
vt <- validate_truncation(visits, settings = list(default = qc_config(),
                                                  curve = qc_config(curve_extrap = "curve")),
                          reps = 2, cores = 4)

# 把四张汇总表保存下来
fwrite(vo$summary, "验证_异常值.csv", bom = TRUE)
fwrite(va$summary, "验证_生长曲线.csv", bom = TRUE)
fwrite(vw$summary, "验证_体重填充.csv", bom = TRUE)
fwrite(vf$summary, "验证_采食插补.csv", bom = TRUE)
fwrite(vt$summary, "验证_整体截断.csv", bom = TRUE)

建议的步骤:

  1. 每个新的测定站或新的批次,先跑一次验证,确定适合的参数;最终比较几套设置时,以 validate_truncation() 为准;
  2. 确定之后,同一个育种群体固定使用这套设置,不要逐批更换,否则年度之间的差异可能来自方法而不是猪;
  3. 验证结果连同 $settings 一起保存,写论文或复查时可以直接引用。

7. 局限

  • 各项验证都假设误差或缺失是随机发生的。实际中,缺失往往和猪的状态有关:例如猪生病时采食少,也更容易缺少记录。这种偏差无法通过验证发现,也无法靠更换方法消除。
  • validate_outliers() 的”干净记录”是各方法的共识,所有方法都漏掉的真实误差仍在其中。
  • validate_weight_fill() 隐藏的是整天的记录,与真实缺失(不进站、耳标脱落)相同;但被隐藏的日子是随机选的,而真实缺失可能与猪的状态有关。
  • validate_adg() 的截断检验以同一模型用全部数据算出的 ADG 为参照,衡量的是稳定性;如果全部数据本身就需要外推,参照值也带有外推误差。
  • validate_truncation() 同样以全部数据的结果为参照,而不是未知的真值;它只用测定区间被完整覆盖的猪,这些猪不一定能代表进站晚、出栏早的猪。