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}
98
99/// The terms of a model, before any data is seen.
100///
101/// ```
102/// use prospicio_models::{Column, Frame, Terms};
103///
104/// let train = Frame::new(vec![
105///     ("age".into(), Column::Numeric(vec![30.0, 45.0, 60.0])),
106///     ("region".into(), Column::Categorical(vec!["N".into(), "S".into(), "W".into()])),
107/// ])
108/// .unwrap();
109/// let coding = Terms::new().intercept().numeric("age").factor("region").fit(&train).unwrap();
110/// assert_eq!(coding.names(), ["(Intercept)", "age", "region[S]", "region[W]"]);
111/// let d = coding.design(&train).unwrap();
112/// assert_eq!(d.column(3), [0.0, 0.0, 1.0]);
113/// ```
114#[derive(Debug, Clone, Default, PartialEq)]
115pub struct Terms {
116    terms: Vec<Term>,
117}
118
119impl Terms {
120    /// No terms.
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    /// Adds an intercept.
126    pub fn intercept(mut self) -> Self {
127        self.terms.push(Term::Intercept);
128        self
129    }
130
131    /// Adds a numeric column.
132    pub fn numeric(mut self, name: &str) -> Self {
133        self.terms.push(Term::Numeric(name.into()));
134        self
135    }
136
137    /// Adds a factor with the default reference level.
138    pub fn factor(mut self, name: &str) -> Self {
139        self.terms.push(Term::Factor {
140            name: name.into(),
141            reference: None,
142        });
143        self
144    }
145
146    /// Adds a factor with the given reference level.
147    pub fn factor_with_reference(mut self, name: &str, reference: &str) -> Self {
148        self.terms.push(Term::Factor {
149            name: name.into(),
150            reference: Some(reference.into()),
151        });
152        self
153    }
154
155    /// Adds any term.
156    pub fn term(mut self, term: Term) -> Self {
157        self.terms.push(term);
158        self
159    }
160
161    /// Learns factor levels from `frame`. Fails if a column is missing or
162    /// has the wrong kind, or a reference level does not occur.
163    pub fn fit(&self, frame: &Frame) -> Result<Coding> {
164        let mut coded = Vec::with_capacity(self.terms.len());
165        for term in &self.terms {
166            coded.push(match term {
167                Term::Intercept => Coded::Intercept,
168                Term::Numeric(name) => match frame.require(name)? {
169                    Column::Numeric(_) => Coded::Numeric(name.clone()),
170                    Column::Categorical(_) => {
171                        return Err(Error::Data(format!("{name:?} is categorical, not numeric")));
172                    }
173                },
174                Term::Factor { name, reference } => {
175                    let Column::Categorical(values) = frame.require(name)? else {
176                        return Err(Error::Data(format!("{name:?} is numeric, not categorical")));
177                    };
178                    let levels: BTreeSet<&String> = values.iter().collect();
179                    let reference = match reference {
180                        Some(r) if levels.contains(r) => r.clone(),
181                        Some(r) => {
182                            return Err(Error::Data(format!(
183                                "reference level {r:?} does not occur in {name:?}"
184                            )));
185                        }
186                        None => levels
187                            .first()
188                            .map(|s| (*s).clone())
189                            .ok_or_else(|| Error::Data(format!("{name:?} is empty")))?,
190                    };
191                    let others = levels
192                        .into_iter()
193                        .filter(|l| **l != reference)
194                        .cloned()
195                        .collect();
196                    Coded::Factor {
197                        name: name.clone(),
198                        reference,
199                        levels: others,
200                    }
201                }
202            });
203        }
204        Ok(Coding { coded })
205    }
206}
207
208#[derive(Debug, Clone, PartialEq)]
209enum Coded {
210    Intercept,
211    Numeric(String),
212    Factor {
213        name: String,
214        reference: String,
215        levels: Vec<String>,
216    },
217}
218
219/// Terms with their factor levels learned: builds the same design matrix
220/// on training data and on new data.
221#[derive(Debug, Clone, PartialEq)]
222pub struct Coding {
223    coded: Vec<Coded>,
224}
225
226impl Coding {
227    /// Column names of the design matrix: `(Intercept)`, numeric names,
228    /// and `name[level]` for each non-reference factor level.
229    pub fn names(&self) -> Vec<String> {
230        let mut names = Vec::new();
231        for c in &self.coded {
232            match c {
233                Coded::Intercept => names.push("(Intercept)".into()),
234                Coded::Numeric(n) => names.push(n.clone()),
235                Coded::Factor { name, levels, .. } => {
236                    names.extend(levels.iter().map(|l| format!("{name}[{l}]")));
237                }
238            }
239        }
240        names
241    }
242
243    /// Reference level of each factor, in term order.
244    pub fn references(&self) -> Vec<(String, String)> {
245        self.coded
246            .iter()
247            .filter_map(|c| match c {
248                Coded::Factor {
249                    name, reference, ..
250                } => Some((name.clone(), reference.clone())),
251                _ => None,
252            })
253            .collect()
254    }
255
256    /// The design matrix for `frame`. Fails if a column is missing, has
257    /// the wrong kind, or holds a factor level not seen in training.
258    pub fn design(&self, frame: &Frame) -> Result<Design> {
259        let n = frame.n_rows();
260        let mut columns: Vec<Vec<f64>> = Vec::new();
261        for c in &self.coded {
262            match c {
263                Coded::Intercept => columns.push(vec![1.0; n]),
264                Coded::Numeric(name) => match frame.require(name)? {
265                    Column::Numeric(v) => columns.push(v.clone()),
266                    Column::Categorical(_) => {
267                        return Err(Error::Data(format!("{name:?} is categorical, not numeric")));
268                    }
269                },
270                Coded::Factor {
271                    name,
272                    reference,
273                    levels,
274                } => {
275                    let Column::Categorical(values) = frame.require(name)? else {
276                        return Err(Error::Data(format!("{name:?} is numeric, not categorical")));
277                    };
278                    let start = columns.len();
279                    columns.extend((0..levels.len()).map(|_| vec![0.0; n]));
280                    for (i, v) in values.iter().enumerate() {
281                        if v == reference {
282                            continue;
283                        }
284                        let k = levels.binary_search(v).map_err(|_| {
285                            Error::Data(format!("level {v:?} of {name:?} was not seen in training"))
286                        })?;
287                        columns[start + k][i] = 1.0;
288                    }
289                }
290            }
291        }
292        Design::new(self.names(), columns)
293    }
294}
295
296/// A design matrix (rows are observations, columns are coefficients), with
297/// an offset and prior weights.
298///
299/// Stored column by column. The offset (default 0) enters the linear
300/// predictor with a fixed coefficient of 1, as `ln(exposure)` does in a
301/// log-link frequency model; the weights (default 1) divide the variance.
302#[derive(Debug, Clone, PartialEq)]
303pub struct Design {
304    names: Vec<String>,
305    columns: Vec<Vec<f64>>,
306    n_rows: usize,
307    offset: Vec<f64>,
308    weights: Vec<f64>,
309}
310
311impl Design {
312    /// A design from named columns. Fails if they differ in length, are
313    /// empty, or hold a non-finite value.
314    pub fn new(names: Vec<String>, columns: Vec<Vec<f64>>) -> Result<Self> {
315        if names.len() != columns.len() {
316            return Err(invalid(
317                "names",
318                names.len() as f64,
319                "must name every column",
320            ));
321        }
322        let n_rows = columns.first().map_or(0, Vec::len);
323        if columns.iter().any(|c| c.len() != n_rows) {
324            return Err(invalid(
325                "columns",
326                n_rows as f64,
327                "must all have the same length",
328            ));
329        }
330        if let Some(bad) = columns.iter().flatten().find(|x| !x.is_finite()) {
331            return Err(invalid("columns", *bad, "must be finite"));
332        }
333        Ok(Self {
334            names,
335            columns,
336            n_rows,
337            offset: vec![0.0; n_rows],
338            weights: vec![1.0; n_rows],
339        })
340    }
341
342    /// Sets the offset, one value per row.
343    pub fn with_offset(mut self, offset: Vec<f64>) -> Result<Self> {
344        if offset.len() != self.n_rows {
345            return Err(invalid(
346                "offset",
347                offset.len() as f64,
348                "needs one value per row",
349            ));
350        }
351        if let Some(bad) = offset.iter().find(|x| !x.is_finite()) {
352            return Err(invalid("offset", *bad, "must be finite"));
353        }
354        self.offset = offset;
355        Ok(self)
356    }
357
358    /// Sets the prior weights, one positive value per row.
359    pub fn with_weights(mut self, weights: Vec<f64>) -> Result<Self> {
360        if weights.len() != self.n_rows {
361            return Err(invalid(
362                "weights",
363                weights.len() as f64,
364                "needs one value per row",
365            ));
366        }
367        if let Some(bad) = weights.iter().find(|x| !(x.is_finite() && **x > 0.0)) {
368            return Err(invalid("weights", *bad, "must be finite and positive"));
369        }
370        self.weights = weights;
371        Ok(self)
372    }
373
374    /// Column names.
375    pub fn names(&self) -> &[String] {
376        &self.names
377    }
378
379    /// Number of rows.
380    pub fn n_rows(&self) -> usize {
381        self.n_rows
382    }
383
384    /// Number of columns.
385    pub fn n_cols(&self) -> usize {
386        self.columns.len()
387    }
388
389    /// Column `j`.
390    pub fn column(&self, j: usize) -> &[f64] {
391        &self.columns[j]
392    }
393
394    /// Offset per row.
395    pub fn offset(&self) -> &[f64] {
396        &self.offset
397    }
398
399    /// Prior weight per row.
400    pub fn weights(&self) -> &[f64] {
401        &self.weights
402    }
403
404    /// `X β + offset`.
405    pub fn linear_predictor(&self, beta: &[f64]) -> Vec<f64> {
406        let mut eta = self.offset.clone();
407        for (col, b) in self.columns.iter().zip(beta) {
408            for (e, x) in eta.iter_mut().zip(col) {
409                *e += b * x;
410            }
411        }
412        eta
413    }
414
415    /// The rows in `rows`, in that order, with their offsets and weights.
416    pub fn select(&self, rows: &[usize]) -> Self {
417        let pick = |v: &[f64]| rows.iter().map(|&i| v[i]).collect::<Vec<_>>();
418        Self {
419            names: self.names.clone(),
420            columns: self.columns.iter().map(|c| pick(c)).collect(),
421            n_rows: rows.len(),
422            offset: pick(&self.offset),
423            weights: pick(&self.weights),
424        }
425    }
426}
427
428fn invalid(name: &'static str, value: f64, reason: &'static str) -> Error {
429    Error::InvalidParameter {
430        name,
431        value,
432        reason,
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    fn frame(regions: &[&str]) -> Frame {
441        Frame::new(vec![
442            (
443                "x".into(),
444                Column::Numeric((0..regions.len()).map(|i| i as f64).collect()),
445            ),
446            (
447                "region".into(),
448                Column::Categorical(regions.iter().map(|s| s.to_string()).collect()),
449            ),
450        ])
451        .unwrap()
452    }
453
454    #[test]
455    fn coding_replays_on_new_data() {
456        let train = frame(&["S", "N", "W", "N"]);
457        let coding = Terms::new()
458            .intercept()
459            .factor_with_reference("region", "S")
460            .numeric("x")
461            .fit(&train)
462            .unwrap();
463        assert_eq!(
464            coding.names(),
465            ["(Intercept)", "region[N]", "region[W]", "x"]
466        );
467        // New data with a subset of levels, in another order.
468        let d = coding.design(&frame(&["W", "S"])).unwrap();
469        assert_eq!(d.column(1), [0.0, 0.0]);
470        assert_eq!(d.column(2), [1.0, 0.0]);
471        assert!(coding.design(&frame(&["E"])).is_err());
472    }
473
474    #[test]
475    fn rejects_bad_frames_and_designs() {
476        assert!(
477            Frame::new(vec![
478                ("a".into(), Column::Numeric(vec![1.0])),
479                ("a".into(), Column::Numeric(vec![2.0])),
480            ])
481            .is_err()
482        );
483        assert!(Terms::new().numeric("region").fit(&frame(&["N"])).is_err());
484        let d = Design::new(vec!["a".into()], vec![vec![1.0, 2.0]]).unwrap();
485        assert!(d.clone().with_weights(vec![1.0, 0.0]).is_err());
486        assert!(d.with_offset(vec![1.0]).is_err());
487    }
488
489    #[test]
490    fn linear_predictor_and_select() {
491        let d = Design::new(
492            vec!["a".into(), "b".into()],
493            vec![vec![1.0, 1.0, 1.0], vec![0.0, 1.0, 2.0]],
494        )
495        .unwrap()
496        .with_offset(vec![0.5, 0.0, 0.0])
497        .unwrap();
498        assert_eq!(d.linear_predictor(&[1.0, 2.0]), [1.5, 3.0, 5.0]);
499        let s = d.select(&[2, 0]);
500        assert_eq!(s.column(1), [2.0, 0.0]);
501        assert_eq!(s.offset(), [0.0, 0.5]);
502    }
503}