1use std::collections::BTreeSet;
11
12use prospicio_core::{Error, Result};
13
14#[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#[derive(Debug, Clone, PartialEq)]
43pub struct Frame {
44 columns: Vec<(String, Column)>,
45 n_rows: usize,
46}
47
48impl Frame {
49 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 pub fn n_rows(&self) -> usize {
69 self.n_rows
70 }
71
72 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#[derive(Debug, Clone, PartialEq)]
85pub enum Term {
86 Intercept,
88 Numeric(String),
90 Factor {
94 name: String,
95 reference: Option<String>,
96 },
97}
98
99#[derive(Debug, Clone, Default, PartialEq)]
115pub struct Terms {
116 terms: Vec<Term>,
117}
118
119impl Terms {
120 pub fn new() -> Self {
122 Self::default()
123 }
124
125 pub fn intercept(mut self) -> Self {
127 self.terms.push(Term::Intercept);
128 self
129 }
130
131 pub fn numeric(mut self, name: &str) -> Self {
133 self.terms.push(Term::Numeric(name.into()));
134 self
135 }
136
137 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 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 pub fn term(mut self, term: Term) -> Self {
157 self.terms.push(term);
158 self
159 }
160
161 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#[derive(Debug, Clone, PartialEq)]
222pub struct Coding {
223 coded: Vec<Coded>,
224}
225
226impl Coding {
227 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 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 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#[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 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 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 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 pub fn names(&self) -> &[String] {
376 &self.names
377 }
378
379 pub fn n_rows(&self) -> usize {
381 self.n_rows
382 }
383
384 pub fn n_cols(&self) -> usize {
386 self.columns.len()
387 }
388
389 pub fn column(&self, j: usize) -> &[f64] {
391 &self.columns[j]
392 }
393
394 pub fn offset(&self) -> &[f64] {
396 &self.offset
397 }
398
399 pub fn weights(&self) -> &[f64] {
401 &self.weights
402 }
403
404 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 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 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}