Skip to main content

prospicio_models/
design.rs

1//! Design matrices: turning named columns into the numeric matrix a model
2//! fits, the same way on training data and on new data.
3//!
4//! [`Terms`] says what goes in (an intercept, numeric columns, factors);
5//! [`Terms::fit`] learns the factor levels from training data and returns
6//! a [`Coding`]; [`Coding::design`] builds a [`Design`] from any frame with
7//! the same columns. A fitted model keeps its `Coding`, so prediction on new
8//! data reproduces the training columns exactly.
9
10use std::collections::BTreeSet;
11
12use prospicio_core::{Error, Result};
13
14/// A named column of data.
15#[derive(Debug, Clone, PartialEq)]
16pub enum Column {
17    Numeric(Vec<f64>),
18    Categorical(Vec<String>),
19}
20
21impl Column {
22    fn len(&self) -> usize {
23        match self {
24            Self::Numeric(v) => v.len(),
25            Self::Categorical(v) => v.len(),
26        }
27    }
28}
29
30/// Named columns of equal length: the input to [`Coding::design`].
31///
32/// ```
33/// use prospicio_models::{Column, Frame};
34///
35/// let frame = Frame::new(vec![
36///     ("age".into(), Column::Numeric(vec![30.0, 45.0])),
37///     ("region".into(), Column::Categorical(vec!["N".into(), "S".into()])),
38/// ])
39/// .unwrap();
40/// assert_eq!(frame.n_rows(), 2);
41/// ```
42#[derive(Debug, Clone, PartialEq)]
43pub struct Frame {
44    columns: Vec<(String, Column)>,
45    n_rows: usize,
46}
47
48impl Frame {
49    /// Fails if the columns differ in length or two share a name.
50    pub fn new(columns: Vec<(String, Column)>) -> Result<Self> {
51        let n_rows = columns.first().map_or(0, |(_, c)| c.len());
52        for (i, (name, c)) in columns.iter().enumerate() {
53            if c.len() != n_rows {
54                return Err(invalid(
55                    "columns",
56                    c.len() as f64,
57                    "must all have the same length",
58                ));
59            }
60            if columns[..i].iter().any(|(other, _)| other == name) {
61                return Err(invalid("columns", i as f64, "repeat a column name"));
62            }
63        }
64        Ok(Self { columns, n_rows })
65    }
66
67    /// Number of rows.
68    pub fn n_rows(&self) -> usize {
69        self.n_rows
70    }
71
72    /// The column called `name`.
73    pub fn column(&self, name: &str) -> Option<&Column> {
74        self.columns.iter().find(|(n, _)| n == name).map(|(_, c)| c)
75    }
76
77    fn require(&self, name: &str) -> Result<&Column> {
78        self.column(name)
79            .ok_or_else(|| Error::Data(format!("no column named {name:?}")))
80    }
81}
82
83/// One term of a model formula.
84#[derive(Debug, Clone, PartialEq)]
85pub enum Term {
86    /// A column of ones.
87    Intercept,
88    /// A numeric column, as is.
89    Numeric(String),
90    /// A categorical column in treatment coding: one indicator column per
91    /// level except the reference. The reference is the given level, or
92    /// the first in sorted order (R's and statsmodels' default).
93    Factor {
94        name: String,
95        reference: Option<String>,
96    },
97    /// The product of two columns, each numeric as is or a factor in
98    /// treatment coding with its first level as reference: `a:b`, `a[x]:b`
99    /// or `a[x]:b[y]`, R's `a:b` beside main effects.
100    Interaction { a: String, b: String },
101    /// Raw powers `x, x², …, x^degree` of a numeric column, named `x`,
102    /// `x^2`, ….
103    Poly { name: String, degree: usize },
104    /// Hinge functions `max(0, x − k)` at each knot, named `hinge(x,k)`.
105    Hinges { name: String, knots: Vec<f64> },
106    /// Steps `1[x ≥ k]` at each knot, named `step(x,k)`.
107    Steps { name: String, knots: Vec<f64> },
108    /// A numeric column cut into bins `(−∞, c₁], (c₁, c₂], …, (c_k, ∞)` and
109    /// coded as a factor (named as R's `cut`, `x[(30,45]]`), the bin with
110    /// index `reference` (default 0, the lowest) as reference.
111    Bins {
112        name: String,
113        cuts: Vec<f64>,
114        reference: Option<usize>,
115    },
116    /// `ln(x / base)`, named `log(x/base)`: a log transform centred so a
117    /// coefficient's base value is `base` (CAS Monograph 5, section 5.6.2).
118    Log { name: String, base: f64 },
119    /// An ordered factor in cumulative coding: for each level after the
120    /// first in `levels`' order, `1[level ≥ it]`, named `x[>=level]`. Each
121    /// coefficient is the step from the level below.
122    Ordinal { name: String, levels: Vec<String> },
123    /// Orthogonal polynomials of degree 1 to `degree` on the training
124    /// values, as R's `poly(x, degree)`, named `poly(x)1`, `poly(x)2`, ….
125    /// The recurrence learned in training rebuilds them on new data.
126    OrthogonalPoly { name: String, degree: usize },
127    /// A natural cubic spline with interior `knots`, linear beyond the
128    /// boundary knots (the training range unless given): `knots.len() + 1`
129    /// columns named `spline(x)1`, …. The basis is the restricted cubic
130    /// (truncated power) one, which spans the same functions as R's
131    /// `splines::ns(x, knots)`: fitted values agree, coefficients differ.
132    NaturalSpline {
133        name: String,
134        knots: Vec<f64>,
135        boundary: Option<(f64, f64)>,
136    },
137}
138
139/// The terms of a model, before any data is seen.
140///
141/// ```
142/// use prospicio_models::{Column, Frame, Terms};
143///
144/// let train = Frame::new(vec![
145///     ("age".into(), Column::Numeric(vec![30.0, 45.0, 60.0])),
146///     ("region".into(), Column::Categorical(vec!["N".into(), "S".into(), "W".into()])),
147/// ])
148/// .unwrap();
149/// let coding = Terms::new().intercept().numeric("age").factor("region").fit(&train).unwrap();
150/// assert_eq!(coding.names(), ["(Intercept)", "age", "region[S]", "region[W]"]);
151/// let d = coding.design(&train).unwrap();
152/// assert_eq!(d.column(3), [0.0, 0.0, 1.0]);
153/// ```
154#[derive(Debug, Clone, Default, PartialEq)]
155pub struct Terms {
156    terms: Vec<Term>,
157}
158
159impl Terms {
160    /// No terms.
161    pub fn new() -> Self {
162        Self::default()
163    }
164
165    /// Adds an intercept.
166    pub fn intercept(mut self) -> Self {
167        self.terms.push(Term::Intercept);
168        self
169    }
170
171    /// Adds a numeric column.
172    pub fn numeric(mut self, name: &str) -> Self {
173        self.terms.push(Term::Numeric(name.into()));
174        self
175    }
176
177    /// Adds a factor with the default reference level.
178    pub fn factor(mut self, name: &str) -> Self {
179        self.terms.push(Term::Factor {
180            name: name.into(),
181            reference: None,
182        });
183        self
184    }
185
186    /// Adds a factor with the given reference level.
187    pub fn factor_with_reference(mut self, name: &str, reference: &str) -> Self {
188        self.terms.push(Term::Factor {
189            name: name.into(),
190            reference: Some(reference.into()),
191        });
192        self
193    }
194
195    /// Adds the interaction of two columns; see [`Term::Interaction`].
196    pub fn interaction(self, a: &str, b: &str) -> Self {
197        self.term(Term::Interaction {
198            a: a.into(),
199            b: b.into(),
200        })
201    }
202
203    /// Adds raw powers of a numeric column up to `degree`.
204    pub fn poly(self, name: &str, degree: usize) -> Self {
205        self.term(Term::Poly {
206            name: name.into(),
207            degree,
208        })
209    }
210
211    /// Adds hinge functions `max(0, x − k)` at `knots`.
212    pub fn hinges(self, name: &str, knots: &[f64]) -> Self {
213        self.term(Term::Hinges {
214            name: name.into(),
215            knots: knots.to_vec(),
216        })
217    }
218
219    /// Adds steps `1[x ≥ k]` at `knots`.
220    pub fn steps(self, name: &str, knots: &[f64]) -> Self {
221        self.term(Term::Steps {
222            name: name.into(),
223            knots: knots.to_vec(),
224        })
225    }
226
227    /// Adds a numeric column cut into bins at `cuts`, the lowest bin as
228    /// reference.
229    pub fn bins(self, name: &str, cuts: &[f64]) -> Self {
230        self.term(Term::Bins {
231            name: name.into(),
232            cuts: cuts.to_vec(),
233            reference: None,
234        })
235    }
236
237    /// Adds `ln(x / base)`.
238    pub fn log(self, name: &str, base: f64) -> Self {
239        self.term(Term::Log {
240            name: name.into(),
241            base,
242        })
243    }
244
245    /// Adds an ordered factor in cumulative coding, levels in `levels`'
246    /// order.
247    pub fn ordinal(self, name: &str, levels: &[&str]) -> Self {
248        self.term(Term::Ordinal {
249            name: name.into(),
250            levels: levels.iter().map(|l| (*l).to_string()).collect(),
251        })
252    }
253
254    /// Adds orthogonal polynomials of a numeric column up to `degree`.
255    pub fn orthogonal_poly(self, name: &str, degree: usize) -> Self {
256        self.term(Term::OrthogonalPoly {
257            name: name.into(),
258            degree,
259        })
260    }
261
262    /// Adds a natural cubic spline with interior `knots`, its boundary
263    /// knots the training range.
264    pub fn natural_spline(self, name: &str, knots: &[f64]) -> Self {
265        self.term(Term::NaturalSpline {
266            name: name.into(),
267            knots: knots.to_vec(),
268            boundary: None,
269        })
270    }
271
272    /// Adds any term.
273    pub fn term(mut self, term: Term) -> Self {
274        self.terms.push(term);
275        self
276    }
277
278    /// Learns factor levels from `frame`. Fails if a column is missing or
279    /// has the wrong kind, or a reference level does not occur.
280    pub fn fit(&self, frame: &Frame) -> Result<Coding> {
281        let mut coded = Vec::with_capacity(self.terms.len());
282        for term in &self.terms {
283            coded.push(match term {
284                Term::Intercept => Coded::Intercept,
285                Term::Numeric(name) => {
286                    numeric(frame, name)?;
287                    Coded::Numeric(name.clone())
288                }
289                Term::Factor { name, reference } => {
290                    let (reference, levels) = factor_levels(frame, name, reference.as_deref())?;
291                    Coded::Factor {
292                        name: name.clone(),
293                        reference,
294                        levels,
295                    }
296                }
297                Term::Interaction { a, b } => Coded::Interaction {
298                    a: side(frame, a)?,
299                    b: side(frame, b)?,
300                },
301                Term::Poly { name, degree } => {
302                    numeric(frame, name)?;
303                    if *degree == 0 {
304                        return Err(invalid("degree", 0.0, "must be at least 1"));
305                    }
306                    Coded::Poly {
307                        name: name.clone(),
308                        degree: *degree,
309                    }
310                }
311                Term::Hinges { name, knots } | Term::Steps { name, knots } => {
312                    numeric(frame, name)?;
313                    increasing(knots, "knots")?;
314                    if matches!(term, Term::Hinges { .. }) {
315                        Coded::Hinges {
316                            name: name.clone(),
317                            knots: knots.clone(),
318                        }
319                    } else {
320                        Coded::Steps {
321                            name: name.clone(),
322                            knots: knots.clone(),
323                        }
324                    }
325                }
326                Term::Bins {
327                    name,
328                    cuts,
329                    reference,
330                } => {
331                    numeric(frame, name)?;
332                    increasing(cuts, "cuts")?;
333                    let reference = reference.unwrap_or(0);
334                    if reference > cuts.len() {
335                        return Err(invalid(
336                            "reference",
337                            reference as f64,
338                            "must index one of the bins",
339                        ));
340                    }
341                    Coded::Bins {
342                        name: name.clone(),
343                        cuts: cuts.clone(),
344                        reference,
345                    }
346                }
347                Term::Log { name, base } => {
348                    numeric(frame, name)?;
349                    if !(base.is_finite() && *base > 0.0) {
350                        return Err(invalid("base", *base, "must be positive"));
351                    }
352                    Coded::Log {
353                        name: name.clone(),
354                        base: *base,
355                    }
356                }
357                Term::OrthogonalPoly { name, degree } => {
358                    let x = numeric(frame, name)?;
359                    orthogonal_poly(name, x, *degree)?
360                }
361                Term::NaturalSpline {
362                    name,
363                    knots,
364                    boundary,
365                } => {
366                    let x = numeric(frame, name)?;
367                    let (lo, hi) = match boundary {
368                        Some(b) => *b,
369                        None => x
370                            .iter()
371                            .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
372                                (lo.min(v), hi.max(v))
373                            }),
374                    };
375                    let mut all = vec![lo];
376                    all.extend(knots);
377                    all.push(hi);
378                    increasing(&all, "knots")?;
379                    if knots.is_empty() {
380                        return Err(invalid(
381                            "knots",
382                            0.0,
383                            "a natural spline needs an interior knot",
384                        ));
385                    }
386                    Coded::NaturalSpline {
387                        name: name.clone(),
388                        knots: all,
389                    }
390                }
391                Term::Ordinal { name, levels } => {
392                    categorical(frame, name)?;
393                    if levels.len() < 2 {
394                        return Err(invalid(
395                            "levels",
396                            levels.len() as f64,
397                            "an ordinal needs at least two levels",
398                        ));
399                    }
400                    if levels
401                        .iter()
402                        .enumerate()
403                        .any(|(i, l)| levels[..i].contains(l))
404                    {
405                        return Err(Error::Data(format!("{name:?} repeats a level")));
406                    }
407                    Coded::Ordinal {
408                        name: name.clone(),
409                        levels: levels.clone(),
410                    }
411                }
412            });
413        }
414        Ok(Coding { coded })
415    }
416}
417
418fn numeric<'a>(frame: &'a Frame, name: &str) -> Result<&'a [f64]> {
419    match frame.require(name)? {
420        Column::Numeric(v) => Ok(v),
421        Column::Categorical(_) => Err(Error::Data(format!("{name:?} is categorical, not numeric"))),
422    }
423}
424
425fn categorical<'a>(frame: &'a Frame, name: &str) -> Result<&'a [String]> {
426    match frame.require(name)? {
427        Column::Categorical(v) => Ok(v),
428        Column::Numeric(_) => Err(Error::Data(format!("{name:?} is numeric, not categorical"))),
429    }
430}
431
432/// The reference and the other levels, sorted, of a factor.
433fn factor_levels(
434    frame: &Frame,
435    name: &str,
436    reference: Option<&str>,
437) -> Result<(String, Vec<String>)> {
438    let values = categorical(frame, name)?;
439    let levels: BTreeSet<&String> = values.iter().collect();
440    let reference = match reference {
441        Some(r) if levels.iter().any(|l| l.as_str() == r) => r.to_string(),
442        Some(r) => {
443            return Err(Error::Data(format!(
444                "reference level {r:?} does not occur in {name:?}"
445            )));
446        }
447        None => levels
448            .first()
449            .map(|s| (*s).clone())
450            .ok_or_else(|| Error::Data(format!("{name:?} is empty")))?,
451    };
452    let others = levels
453        .into_iter()
454        .filter(|l| **l != reference)
455        .cloned()
456        .collect();
457    Ok((reference, others))
458}
459
460fn side(frame: &Frame, name: &str) -> Result<Side> {
461    Ok(match frame.require(name)? {
462        Column::Numeric(_) => Side::Numeric(name.to_string()),
463        Column::Categorical(_) => {
464            let (reference, levels) = factor_levels(frame, name, None)?;
465            Side::Factor {
466                name: name.to_string(),
467                reference,
468                levels,
469            }
470        }
471    })
472}
473
474fn increasing(v: &[f64], name: &'static str) -> Result<()> {
475    if v.is_empty() {
476        return Err(invalid(name, 0.0, "must not be empty"));
477    }
478    if let Some(&bad) = v.iter().find(|x| !x.is_finite()) {
479        return Err(invalid(name, bad, "must be finite"));
480    }
481    if v.windows(2).any(|w| w[0] >= w[1]) {
482        return Err(invalid(name, v.len() as f64, "must be strictly increasing"));
483    }
484    Ok(())
485}
486
487/// One side of an interaction.
488#[derive(Debug, Clone, PartialEq)]
489enum Side {
490    Numeric(String),
491    Factor {
492        name: String,
493        reference: String,
494        levels: Vec<String>,
495    },
496}
497
498impl Side {
499    fn names(&self) -> Vec<String> {
500        match self {
501            Self::Numeric(n) => vec![n.clone()],
502            Self::Factor { name, levels, .. } => {
503                levels.iter().map(|l| format!("{name}[{l}]")).collect()
504            }
505        }
506    }
507
508    fn columns(&self, frame: &Frame) -> Result<Vec<Vec<f64>>> {
509        match self {
510            Self::Numeric(n) => Ok(vec![numeric(frame, n)?.to_vec()]),
511            Self::Factor {
512                name,
513                reference,
514                levels,
515            } => indicators(categorical(frame, name)?, name, reference, levels),
516        }
517    }
518}
519
520/// Treatment-coded indicator columns of a factor's values.
521fn indicators(
522    values: &[String],
523    name: &str,
524    reference: &str,
525    levels: &[String],
526) -> Result<Vec<Vec<f64>>> {
527    let mut columns = vec![vec![0.0; values.len()]; levels.len()];
528    for (i, v) in values.iter().enumerate() {
529        if v == reference {
530            continue;
531        }
532        let k = levels.binary_search(v).map_err(|_| {
533            Error::Data(format!("level {v:?} of {name:?} was not seen in training"))
534        })?;
535        columns[k][i] = 1.0;
536    }
537    Ok(columns)
538}
539
540/// The recurrence of R's `poly(x, degree)` on the training values `x`.
541fn orthogonal_poly(name: &str, x: &[f64], degree: usize) -> Result<Coded> {
542    let distinct: BTreeSet<u64> = x.iter().map(|v| v.to_bits()).collect();
543    if degree == 0 || degree >= distinct.len() {
544        return Err(invalid(
545            "degree",
546            degree as f64,
547            "must be at least 1 and less than the number of distinct values",
548        ));
549    }
550    let n = x.len();
551    let (mut alpha, mut norm2) = (Vec::with_capacity(degree), vec![1.0, n as f64]);
552    let (mut prev, mut cur) = (vec![0.0; n], vec![1.0; n]);
553    for k in 0..degree {
554        let a = x.iter().zip(&cur).map(|(x, p)| x * p * p).sum::<f64>() / norm2[k + 1];
555        let ratio = norm2[k + 1] / norm2[k];
556        let next: Vec<f64> = (0..n)
557            .map(|i| (x[i] - a) * cur[i] - if k == 0 { 0.0 } else { ratio * prev[i] })
558            .collect();
559        alpha.push(a);
560        norm2.push(next.iter().map(|p| p * p).sum());
561        prev = std::mem::replace(&mut cur, next);
562    }
563    Ok(Coded::OrthogonalPoly {
564        name: name.to_string(),
565        alpha,
566        norm2,
567    })
568}
569
570fn poly_columns(x: &[f64], alpha: &[f64], norm2: &[f64]) -> Vec<Vec<f64>> {
571    let n = x.len();
572    let (mut prev, mut cur) = (vec![0.0; n], vec![1.0; n]);
573    let mut out = Vec::with_capacity(alpha.len());
574    for (k, a) in alpha.iter().enumerate() {
575        let ratio = norm2[k + 1] / norm2[k];
576        let next: Vec<f64> = (0..n)
577            .map(|i| (x[i] - a) * cur[i] - if k == 0 { 0.0 } else { ratio * prev[i] })
578            .collect();
579        let scale = norm2[k + 2].sqrt();
580        out.push(next.iter().map(|p| p / scale).collect());
581        prev = std::mem::replace(&mut cur, next);
582    }
583    out
584}
585
586/// The restricted cubic spline basis on knots `t₁ < … < t_K`: `x`, then
587/// `(d_j − d_{K−1}) / (t_K − t₁)²` for `j < K − 1`, with
588/// `d_j(x) = ((x − t_j)³₊ − (x − t_K)³₊) / (t_K − t_j)`; linear beyond the
589/// boundary knots.
590fn spline_columns(x: &[f64], knots: &[f64]) -> Vec<Vec<f64>> {
591    let k = knots.len();
592    let last = knots[k - 1];
593    let scale = (last - knots[0]).powi(2);
594    let cube = |v: f64| v.max(0.0).powi(3);
595    let d = |j: usize, v: f64| (cube(v - knots[j]) - cube(v - last)) / (last - knots[j]);
596    let mut out = vec![x.to_vec()];
597    out.extend((0..k - 2).map(|j| x.iter().map(|&v| (d(j, v) - d(k - 2, v)) / scale).collect()));
598    out
599}
600
601/// A bin's label, as R's `cut`: `(a,b]`, open at both infinite ends.
602fn bin_label(cuts: &[f64], k: usize) -> String {
603    let lo = if k == 0 {
604        "-Inf".to_string()
605    } else {
606        cuts[k - 1].to_string()
607    };
608    if k == cuts.len() {
609        format!("({lo},Inf)")
610    } else {
611        format!("({lo},{}]", cuts[k])
612    }
613}
614
615#[derive(Debug, Clone, PartialEq)]
616enum Coded {
617    Intercept,
618    Numeric(String),
619    Factor {
620        name: String,
621        reference: String,
622        levels: Vec<String>,
623    },
624    Interaction {
625        a: Side,
626        b: Side,
627    },
628    Poly {
629        name: String,
630        degree: usize,
631    },
632    Hinges {
633        name: String,
634        knots: Vec<f64>,
635    },
636    Steps {
637        name: String,
638        knots: Vec<f64>,
639    },
640    Bins {
641        name: String,
642        cuts: Vec<f64>,
643        reference: usize,
644    },
645    Log {
646        name: String,
647        base: f64,
648    },
649    Ordinal {
650        name: String,
651        levels: Vec<String>,
652    },
653    /// R's `poly` recurrence: `p₀ = 1`, `p₁ = x − α₁`,
654    /// `p_{k+1} = (x − α_{k+1}) p_k − (n_{k+1} / n_k) p_{k−1}`, each column
655    /// `p_k / √n_{k+1}`; `norm2` is `[1, n₁, …, n_{degree+1}]`.
656    OrthogonalPoly {
657        name: String,
658        alpha: Vec<f64>,
659        norm2: Vec<f64>,
660    },
661    /// Every knot, boundaries included.
662    NaturalSpline {
663        name: String,
664        knots: Vec<f64>,
665    },
666}
667
668/// Terms with their factor levels learned: builds the same design matrix
669/// on training data and on new data.
670#[derive(Debug, Clone, PartialEq)]
671pub struct Coding {
672    coded: Vec<Coded>,
673}
674
675impl Coding {
676    /// Column names of the design matrix: `(Intercept)`, numeric names,
677    /// and `name[level]` for each non-reference factor level.
678    pub fn names(&self) -> Vec<String> {
679        let mut names = Vec::new();
680        for c in &self.coded {
681            match c {
682                Coded::Intercept => names.push("(Intercept)".into()),
683                Coded::Numeric(n) => names.push(n.clone()),
684                Coded::Factor { name, levels, .. } => {
685                    names.extend(levels.iter().map(|l| format!("{name}[{l}]")));
686                }
687                Coded::Interaction { a, b } => {
688                    let (na, nb) = (a.names(), b.names());
689                    for y in &nb {
690                        names.extend(na.iter().map(|x| format!("{x}:{y}")));
691                    }
692                }
693                Coded::Poly { name, degree } => {
694                    names.push(name.clone());
695                    names.extend((2..=*degree).map(|k| format!("{name}^{k}")));
696                }
697                Coded::Hinges { name, knots } => {
698                    names.extend(knots.iter().map(|k| format!("hinge({name},{k})")));
699                }
700                Coded::Steps { name, knots } => {
701                    names.extend(knots.iter().map(|k| format!("step({name},{k})")));
702                }
703                Coded::Bins {
704                    name,
705                    cuts,
706                    reference,
707                } => names.extend(
708                    (0..=cuts.len())
709                        .filter(|k| k != reference)
710                        .map(|k| format!("{name}[{}]", bin_label(cuts, k))),
711                ),
712                Coded::Log { name, base } => names.push(format!("log({name}/{base})")),
713                Coded::Ordinal { name, levels } => {
714                    names.extend(levels[1..].iter().map(|l| format!("{name}[>={l}]")));
715                }
716                Coded::OrthogonalPoly { name, alpha, .. } => {
717                    names.extend((1..=alpha.len()).map(|k| format!("poly({name}){k}")));
718                }
719                Coded::NaturalSpline { name, knots } => {
720                    names.extend((1..knots.len()).map(|k| format!("spline({name}){k}")));
721                }
722            }
723        }
724        names
725    }
726
727    /// Reference level of each factor, in term order.
728    pub fn references(&self) -> Vec<(String, String)> {
729        self.coded
730            .iter()
731            .filter_map(|c| match c {
732                Coded::Factor {
733                    name, reference, ..
734                } => Some((name.clone(), reference.clone())),
735                _ => None,
736            })
737            .collect()
738    }
739
740    /// The design matrix for `frame`. Fails if a column is missing, has
741    /// the wrong kind, or holds a factor level not seen in training.
742    pub fn design(&self, frame: &Frame) -> Result<Design> {
743        let n = frame.n_rows();
744        let mut columns: Vec<Vec<f64>> = Vec::new();
745        for c in &self.coded {
746            match c {
747                Coded::Intercept => columns.push(vec![1.0; n]),
748                Coded::Numeric(name) => columns.push(numeric(frame, name)?.to_vec()),
749                Coded::Factor {
750                    name,
751                    reference,
752                    levels,
753                } => columns.extend(indicators(
754                    categorical(frame, name)?,
755                    name,
756                    reference,
757                    levels,
758                )?),
759                Coded::Interaction { a, b } => {
760                    let (ca, cb) = (a.columns(frame)?, b.columns(frame)?);
761                    for y in &cb {
762                        for x in &ca {
763                            columns.push(x.iter().zip(y).map(|(x, y)| x * y).collect());
764                        }
765                    }
766                }
767                Coded::Poly { name, degree } => {
768                    let x = numeric(frame, name)?;
769                    columns.extend(
770                        (1..=*degree).map(|k| x.iter().map(|v| v.powi(k as i32)).collect()),
771                    );
772                }
773                Coded::Hinges { name, knots } => {
774                    let x = numeric(frame, name)?;
775                    columns.extend(
776                        knots
777                            .iter()
778                            .map(|k| x.iter().map(|v| (v - k).max(0.0)).collect()),
779                    );
780                }
781                Coded::Steps { name, knots } => {
782                    let x = numeric(frame, name)?;
783                    columns.extend(
784                        knots
785                            .iter()
786                            .map(|k| x.iter().map(|v| if v >= k { 1.0 } else { 0.0 }).collect()),
787                    );
788                }
789                Coded::Bins {
790                    name,
791                    cuts,
792                    reference,
793                } => {
794                    let x = numeric(frame, name)?;
795                    let start = columns.len();
796                    columns.extend((0..cuts.len()).map(|_| vec![0.0; n]));
797                    for (i, v) in x.iter().enumerate() {
798                        if v.is_nan() {
799                            return Err(Error::Data(format!("{name:?} holds NaN")));
800                        }
801                        // The bin (c_{k-1}, c_k]: the number of cuts below v.
802                        let k = cuts.partition_point(|c| c < v);
803                        if k != *reference {
804                            let col = if k < *reference { k } else { k - 1 };
805                            columns[start + col][i] = 1.0;
806                        }
807                    }
808                }
809                Coded::Log { name, base } => {
810                    let x = numeric(frame, name)?;
811                    if let Some(&bad) = x.iter().find(|v| v.is_nan() || **v <= 0.0) {
812                        return Err(Error::Data(format!(
813                            "log({name}/{base}) needs positive values, got {bad}"
814                        )));
815                    }
816                    columns.push(x.iter().map(|v| (v / base).ln()).collect());
817                }
818                Coded::OrthogonalPoly { alpha, norm2, name } => {
819                    columns.extend(poly_columns(numeric(frame, name)?, alpha, norm2));
820                }
821                Coded::NaturalSpline { name, knots } => {
822                    columns.extend(spline_columns(numeric(frame, name)?, knots));
823                }
824                Coded::Ordinal { name, levels } => {
825                    let values = categorical(frame, name)?;
826                    let start = columns.len();
827                    columns.extend((1..levels.len()).map(|_| vec![0.0; n]));
828                    for (i, v) in values.iter().enumerate() {
829                        let rank = levels.iter().position(|l| l == v).ok_or_else(|| {
830                            Error::Data(format!("level {v:?} of {name:?} is not one of its levels"))
831                        })?;
832                        for col in columns[start..start + rank].iter_mut() {
833                            col[i] = 1.0;
834                        }
835                    }
836                }
837            }
838        }
839        Design::new(self.names(), columns)
840    }
841}
842
843/// A design matrix (rows are observations, columns are coefficients), with
844/// an offset and prior weights.
845///
846/// Stored column by column. The offset (default 0) enters the linear
847/// predictor with a fixed coefficient of 1, as `ln(exposure)` does in a
848/// log-link frequency model; the weights (default 1) divide the variance.
849#[derive(Debug, Clone, PartialEq)]
850pub struct Design {
851    names: Vec<String>,
852    columns: Vec<Vec<f64>>,
853    n_rows: usize,
854    offset: Vec<f64>,
855    weights: Vec<f64>,
856}
857
858impl Design {
859    /// A design from named columns. Fails if they differ in length, are
860    /// empty, or hold a non-finite value.
861    pub fn new(names: Vec<String>, columns: Vec<Vec<f64>>) -> Result<Self> {
862        if names.len() != columns.len() {
863            return Err(invalid(
864                "names",
865                names.len() as f64,
866                "must name every column",
867            ));
868        }
869        let n_rows = columns.first().map_or(0, Vec::len);
870        if columns.iter().any(|c| c.len() != n_rows) {
871            return Err(invalid(
872                "columns",
873                n_rows as f64,
874                "must all have the same length",
875            ));
876        }
877        if let Some(bad) = columns.iter().flatten().find(|x| !x.is_finite()) {
878            return Err(invalid("columns", *bad, "must be finite"));
879        }
880        Ok(Self {
881            names,
882            columns,
883            n_rows,
884            offset: vec![0.0; n_rows],
885            weights: vec![1.0; n_rows],
886        })
887    }
888
889    /// Sets the offset, one value per row.
890    pub fn with_offset(mut self, offset: Vec<f64>) -> Result<Self> {
891        if offset.len() != self.n_rows {
892            return Err(invalid(
893                "offset",
894                offset.len() as f64,
895                "needs one value per row",
896            ));
897        }
898        if let Some(bad) = offset.iter().find(|x| !x.is_finite()) {
899            return Err(invalid("offset", *bad, "must be finite"));
900        }
901        self.offset = offset;
902        Ok(self)
903    }
904
905    /// Sets the prior weights, one positive value per row.
906    pub fn with_weights(mut self, weights: Vec<f64>) -> Result<Self> {
907        if weights.len() != self.n_rows {
908            return Err(invalid(
909                "weights",
910                weights.len() as f64,
911                "needs one value per row",
912            ));
913        }
914        if let Some(bad) = weights.iter().find(|x| !(x.is_finite() && **x > 0.0)) {
915            return Err(invalid("weights", *bad, "must be finite and positive"));
916        }
917        self.weights = weights;
918        Ok(self)
919    }
920
921    /// Column names.
922    pub fn names(&self) -> &[String] {
923        &self.names
924    }
925
926    /// Number of rows.
927    pub fn n_rows(&self) -> usize {
928        self.n_rows
929    }
930
931    /// Number of columns.
932    pub fn n_cols(&self) -> usize {
933        self.columns.len()
934    }
935
936    /// Column `j`.
937    pub fn column(&self, j: usize) -> &[f64] {
938        &self.columns[j]
939    }
940
941    /// Offset per row.
942    pub fn offset(&self) -> &[f64] {
943        &self.offset
944    }
945
946    /// Prior weight per row.
947    pub fn weights(&self) -> &[f64] {
948        &self.weights
949    }
950
951    /// `X β + offset`.
952    pub fn linear_predictor(&self, beta: &[f64]) -> Vec<f64> {
953        let mut eta = self.offset.clone();
954        for (col, b) in self.columns.iter().zip(beta) {
955            for (e, x) in eta.iter_mut().zip(col) {
956                *e += b * x;
957            }
958        }
959        eta
960    }
961
962    /// The rows in `rows`, in that order, with their offsets and weights.
963    pub fn select(&self, rows: &[usize]) -> Self {
964        let pick = |v: &[f64]| rows.iter().map(|&i| v[i]).collect::<Vec<_>>();
965        Self {
966            names: self.names.clone(),
967            columns: self.columns.iter().map(|c| pick(c)).collect(),
968            n_rows: rows.len(),
969            offset: pick(&self.offset),
970            weights: pick(&self.weights),
971        }
972    }
973}
974
975fn invalid(name: &'static str, value: f64, reason: &'static str) -> Error {
976    Error::InvalidParameter {
977        name,
978        value,
979        reason,
980    }
981}
982
983#[cfg(test)]
984mod tests {
985    use super::*;
986
987    fn frame(regions: &[&str]) -> Frame {
988        Frame::new(vec![
989            (
990                "x".into(),
991                Column::Numeric((0..regions.len()).map(|i| i as f64).collect()),
992            ),
993            (
994                "region".into(),
995                Column::Categorical(regions.iter().map(|s| s.to_string()).collect()),
996            ),
997        ])
998        .unwrap()
999    }
1000
1001    #[test]
1002    fn coding_replays_on_new_data() {
1003        let train = frame(&["S", "N", "W", "N"]);
1004        let coding = Terms::new()
1005            .intercept()
1006            .factor_with_reference("region", "S")
1007            .numeric("x")
1008            .fit(&train)
1009            .unwrap();
1010        assert_eq!(
1011            coding.names(),
1012            ["(Intercept)", "region[N]", "region[W]", "x"]
1013        );
1014        // New data with a subset of levels, in another order.
1015        let d = coding.design(&frame(&["W", "S"])).unwrap();
1016        assert_eq!(d.column(1), [0.0, 0.0]);
1017        assert_eq!(d.column(2), [1.0, 0.0]);
1018        assert!(coding.design(&frame(&["E"])).is_err());
1019    }
1020
1021    #[test]
1022    fn rejects_bad_frames_and_designs() {
1023        assert!(
1024            Frame::new(vec![
1025                ("a".into(), Column::Numeric(vec![1.0])),
1026                ("a".into(), Column::Numeric(vec![2.0])),
1027            ])
1028            .is_err()
1029        );
1030        assert!(Terms::new().numeric("region").fit(&frame(&["N"])).is_err());
1031        let d = Design::new(vec!["a".into()], vec![vec![1.0, 2.0]]).unwrap();
1032        assert!(d.clone().with_weights(vec![1.0, 0.0]).is_err());
1033        assert!(d.with_offset(vec![1.0]).is_err());
1034    }
1035
1036    #[test]
1037    fn linear_predictor_and_select() {
1038        let d = Design::new(
1039            vec!["a".into(), "b".into()],
1040            vec![vec![1.0, 1.0, 1.0], vec![0.0, 1.0, 2.0]],
1041        )
1042        .unwrap()
1043        .with_offset(vec![0.5, 0.0, 0.0])
1044        .unwrap();
1045        assert_eq!(d.linear_predictor(&[1.0, 2.0]), [1.5, 3.0, 5.0]);
1046        let s = d.select(&[2, 0]);
1047        assert_eq!(s.column(1), [2.0, 0.0]);
1048        assert_eq!(s.offset(), [0.0, 0.5]);
1049    }
1050
1051    fn rating_frame() -> Frame {
1052        Frame::new(vec![
1053            ("age".into(), Column::Numeric(vec![20.0, 30.0, 45.0, 60.0])),
1054            (
1055                "region".into(),
1056                Column::Categorical(["N", "S", "W", "S"].iter().map(|s| s.to_string()).collect()),
1057            ),
1058            (
1059                "size".into(),
1060                Column::Categorical(
1061                    ["small", "large", "medium", "small"]
1062                        .iter()
1063                        .map(|s| s.to_string())
1064                        .collect(),
1065                ),
1066            ),
1067        ])
1068        .unwrap()
1069    }
1070
1071    #[test]
1072    fn interactions_multiply_the_coded_columns() {
1073        let f = rating_frame();
1074        let c = Terms::new()
1075            .interaction("region", "age")
1076            .interaction("region", "size")
1077            .interaction("age", "age")
1078            .fit(&f)
1079            .unwrap();
1080        assert_eq!(
1081            c.names(),
1082            [
1083                "region[S]:age",
1084                "region[W]:age",
1085                // The first factor varies fastest, as R's a:b.
1086                "region[S]:size[medium]",
1087                "region[W]:size[medium]",
1088                "region[S]:size[small]",
1089                "region[W]:size[small]",
1090                "age:age",
1091            ]
1092        );
1093        let d = c.design(&f).unwrap();
1094        assert_eq!(d.column(0), [0.0, 30.0, 0.0, 60.0]);
1095        assert_eq!(d.column(3), [0.0, 0.0, 1.0, 0.0]);
1096        assert_eq!(d.column(4), [0.0, 0.0, 0.0, 1.0]);
1097        assert_eq!(d.column(6), [400.0, 900.0, 2025.0, 3600.0]);
1098    }
1099
1100    #[test]
1101    fn polynomials_hinges_steps_and_logs() {
1102        let f = rating_frame();
1103        let c = Terms::new()
1104            .poly("age", 3)
1105            .hinges("age", &[25.0, 50.0])
1106            .steps("age", &[30.0])
1107            .log("age", 40.0)
1108            .fit(&f)
1109            .unwrap();
1110        assert_eq!(
1111            c.names(),
1112            [
1113                "age",
1114                "age^2",
1115                "age^3",
1116                "hinge(age,25)",
1117                "hinge(age,50)",
1118                "step(age,30)",
1119                "log(age/40)"
1120            ]
1121        );
1122        let d = c.design(&f).unwrap();
1123        assert_eq!(d.column(2), [8000.0, 27000.0, 91125.0, 216000.0]);
1124        assert_eq!(d.column(3), [0.0, 5.0, 20.0, 35.0]);
1125        assert_eq!(d.column(4), [0.0, 0.0, 0.0, 10.0]);
1126        // A step includes its knot.
1127        assert_eq!(d.column(5), [0.0, 1.0, 1.0, 1.0]);
1128        assert!((d.column(6)[2] - (45.0f64 / 40.0).ln()).abs() < 1e-15);
1129        assert!(Terms::new().hinges("age", &[50.0, 25.0]).fit(&f).is_err());
1130        assert!(Terms::new().poly("age", 0).fit(&f).is_err());
1131        assert!(Terms::new().log("age", 0.0).fit(&f).is_err());
1132    }
1133
1134    #[test]
1135    fn bins_code_a_numeric_column_as_a_factor() {
1136        let f = rating_frame();
1137        let c = Terms::new().bins("age", &[30.0, 50.0]).fit(&f).unwrap();
1138        // Right-closed, as R's cut: 30 falls in (-Inf,30].
1139        assert_eq!(c.names(), ["age[(30,50]]", "age[(50,Inf)]"]);
1140        let d = c.design(&f).unwrap();
1141        assert_eq!(d.column(0), [0.0, 0.0, 1.0, 0.0]);
1142        assert_eq!(d.column(1), [0.0, 0.0, 0.0, 1.0]);
1143        let middle = Terms::new()
1144            .term(Term::Bins {
1145                name: "age".into(),
1146                cuts: vec![30.0, 50.0],
1147                reference: Some(1),
1148            })
1149            .fit(&f)
1150            .unwrap();
1151        assert_eq!(middle.names(), ["age[(-Inf,30]]", "age[(50,Inf)]"]);
1152        assert_eq!(middle.design(&f).unwrap().column(0), [1.0, 1.0, 0.0, 0.0]);
1153        // Values outside the training range still fall in a bin.
1154        let new = Frame::new(vec![("age".into(), Column::Numeric(vec![-5.0, 99.0]))]).unwrap();
1155        assert_eq!(c.design(&new).unwrap().column(1), [0.0, 1.0]);
1156    }
1157
1158    #[test]
1159    fn ordinals_are_cumulative_steps() {
1160        let f = rating_frame();
1161        let c = Terms::new()
1162            .ordinal("size", &["small", "medium", "large"])
1163            .fit(&f)
1164            .unwrap();
1165        assert_eq!(c.names(), ["size[>=medium]", "size[>=large]"]);
1166        let d = c.design(&f).unwrap();
1167        assert_eq!(d.column(0), [0.0, 1.0, 1.0, 0.0]);
1168        assert_eq!(d.column(1), [0.0, 1.0, 0.0, 0.0]);
1169        let unknown = Frame::new(vec![(
1170            "size".into(),
1171            Column::Categorical(vec!["huge".into()]),
1172        )])
1173        .unwrap();
1174        assert!(c.design(&unknown).is_err());
1175        assert!(
1176            Terms::new()
1177                .ordinal("size", &["small", "small"])
1178                .fit(&f)
1179                .is_err()
1180        );
1181    }
1182}