Skip to main content

prospicio_models/
encode.rs

1//! Out-of-fold target encoding of a high-cardinality factor with
2//! credibility (Holmes and Casotto, CAS Monograph 16, chapter 6).
3//!
4//! Each level's encoding is its actual-to-expected ratio shrunk to 1 by
5//! the credibility `z = n / (n + K)`, with `n` the level's exposure:
6//! `z A / E + (1 − z)`. The expected values come from the caller (the
7//! overall mean times exposure, or a first model's predictions), so the
8//! ratio measures what that model misses. On the training rows each row is
9//! encoded from the other folds only, so a row's own response never enters
10//! its encoding; new data is encoded from all the training rows.
11
12use std::collections::BTreeMap;
13
14use prospicio_core::{Error, Result};
15
16use crate::resample::Split;
17
18/// The encoding of each level learned from all the training rows, from
19/// [`target_encode`]; [`encode`](Self::encode) applies it to new data.
20#[derive(Debug, Clone, PartialEq)]
21pub struct TargetEncoding {
22    /// The levels seen in training, sorted.
23    pub levels: Vec<String>,
24    /// Each level's credibility-weighted actual-to-expected ratio.
25    pub ratios: Vec<f64>,
26    /// The credibility constant `K`.
27    pub k: f64,
28}
29
30impl TargetEncoding {
31    /// The encoding of each value; a level not seen in training gets 1, no
32    /// credibility.
33    pub fn encode(&self, values: &[String]) -> Vec<f64> {
34        values
35            .iter()
36            .map(|v| match self.levels.binary_search(v) {
37                Ok(i) => self.ratios[i],
38                Err(_) => 1.0,
39            })
40            .collect()
41    }
42}
43
44/// The out-of-fold encoding of each training row, and the encoding from all
45/// the rows for new data.
46///
47/// `values` are the factor's levels per row, `y` the actual responses,
48/// `expected` the expected responses, `exposure` the credibility weights
49/// (default 1 per row), and every row is in exactly one split's test set.
50/// A row is encoded from its split's training rows; within those, a level
51/// with no expected loss gets 1.
52///
53/// ```
54/// use prospicio_models::encode::target_encode;
55/// use prospicio_models::resample::Split;
56///
57/// let values: Vec<String> = ["a", "a", "b", "b"].iter().map(|s| s.to_string()).collect();
58/// let splits = vec![
59///     Split { train: vec![1, 3], test: vec![0, 2] },
60///     Split { train: vec![0, 2], test: vec![1, 3] },
61/// ];
62/// let (rows, all) =
63///     target_encode(&values, &[2.0, 2.0, 0.0, 1.0], &[1.0; 4], None, &splits, 1.0).unwrap();
64/// // Row 0 sees only row 1 of level a: z = 1/2, ratio 2, so 1.5.
65/// assert_eq!(rows[0], 1.5);
66/// // From all rows, a has n = 2, z = 2/3 and ratio 2.
67/// assert!((all.encode(&["a".to_string()])[0] - 5.0 / 3.0).abs() < 1e-15);
68/// ```
69///
70/// Fails when the lengths differ, `k` is negative, or a row is in no test
71/// set or several.
72pub fn target_encode(
73    values: &[String],
74    y: &[f64],
75    expected: &[f64],
76    exposure: Option<&[f64]>,
77    splits: &[Split],
78    k: f64,
79) -> Result<(Vec<f64>, TargetEncoding)> {
80    let n = values.len();
81    for (len, name) in [(y.len(), "y"), (expected.len(), "expected")] {
82        if len != n {
83            return Err(Error::InvalidParameter {
84                name,
85                value: len as f64,
86                reason: "must have one value per row",
87            });
88        }
89    }
90    if let Some(e) = exposure {
91        if e.len() != n {
92            return Err(Error::InvalidParameter {
93                name: "exposure",
94                value: e.len() as f64,
95                reason: "must have one value per row",
96            });
97        }
98    }
99    if !(k.is_finite() && k >= 0.0) {
100        return Err(Error::InvalidParameter {
101            name: "k",
102            value: k,
103            reason: "must be finite and non-negative",
104        });
105    }
106    let mut seen = vec![false; n];
107    for s in splits {
108        for &i in &s.test {
109            if i >= n || seen[i] {
110                return Err(Error::InvalidParameter {
111                    name: "splits",
112                    value: i as f64,
113                    reason: "every row must be in exactly one test set",
114                });
115            }
116            seen[i] = true;
117        }
118    }
119    if let Some(i) = seen.iter().position(|s| !s) {
120        return Err(Error::InvalidParameter {
121            name: "splits",
122            value: i as f64,
123            reason: "every row must be in exactly one test set",
124        });
125    }
126    let e = |i: usize| exposure.map_or(1.0, |e| e[i]);
127    let stats = |rows: &mut dyn Iterator<Item = usize>| {
128        let mut by: BTreeMap<&str, (f64, f64, f64)> = BTreeMap::new();
129        for i in rows {
130            let s = by.entry(values[i].as_str()).or_default();
131            s.0 += y[i];
132            s.1 += expected[i];
133            s.2 += e(i);
134        }
135        by
136    };
137    let ratio = |(actual, expected, exposure): (f64, f64, f64)| {
138        let z = if exposure + k > 0.0 {
139            exposure / (exposure + k)
140        } else {
141            0.0
142        };
143        if expected == 0.0 {
144            1.0
145        } else {
146            z * actual / expected + (1.0 - z)
147        }
148    };
149    let mut rows = vec![1.0; n];
150    for s in splits {
151        let by = stats(&mut s.train.iter().copied());
152        for &i in &s.test {
153            rows[i] = by.get(values[i].as_str()).copied().map_or(1.0, ratio);
154        }
155    }
156    let by = stats(&mut (0..n));
157    let encoding = TargetEncoding {
158        levels: by.keys().map(|l| l.to_string()).collect(),
159        ratios: by.values().copied().map(ratio).collect(),
160        k,
161    };
162    Ok((rows, encoding))
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::resample::k_fold;
169
170    #[test]
171    fn a_row_never_sees_its_own_response() {
172        let values: Vec<String> = (0..60).map(|i| format!("l{}", i % 7)).collect();
173        let y: Vec<f64> = (0..60).map(|i| ((i * 13) % 5) as f64).collect();
174        let expected = vec![2.0; 60];
175        let splits = k_fold(60, 5, 3).unwrap();
176        let (rows, all) = target_encode(&values, &y, &expected, None, &splits, 4.0).unwrap();
177        // Change one row's response: its own encoding does not move.
178        let mut y2 = y.clone();
179        y2[10] += 100.0;
180        let (rows2, _) = target_encode(&values, &y2, &expected, None, &splits, 4.0).unwrap();
181        assert_eq!(rows[10], rows2[10]);
182        assert!(rows2.iter().zip(&rows).any(|(a, b)| a != b));
183        // K = 0 is the plain ratio, and an unseen level is 1.
184        let (_, plain) = target_encode(&values, &y, &expected, None, &splits, 0.0).unwrap();
185        let a: f64 = (0..60).filter(|i| i % 7 == 0).map(|i| y[i]).sum();
186        let n = (0..60).filter(|i| i % 7 == 0).count() as f64;
187        assert!((plain.encode(&["l0".into()])[0] - a / (2.0 * n)).abs() < 1e-12);
188        assert_eq!(all.encode(&["new".into()]), [1.0]);
189        assert!(target_encode(&values, &y, &expected, None, &splits[1..], 4.0).is_err());
190    }
191}