use std::collections::{BTreeSet, HashMap};
use super::error::Error;
use super::parse::{parse, ParsedFormula, RandomEffect};
use crate::{
ColumnId, Family, FitOptions, GroupIds, Grouping, GroupingRelation, ModelSpec, ReStructure,
Sizing,
};
pub enum Column {
Numeric(Vec<f64>),
Factor {
levels: Vec<String>,
codes: Vec<u32>,
},
}
impl Column {
pub fn factor_from_labels(labels: &[String]) -> Column {
let (levels, codes) = sorted_levels_and_codes(labels);
Column::Factor { levels, codes }
}
}
pub struct Table {
pub columns: Vec<(String, Column)>,
pub n: usize,
}
impl Table {
fn get(&self, name: &str) -> Option<&Column> {
self.columns.iter().find(|(n, _)| n == name).map(|(_, c)| c)
}
}
pub struct ReGroupInfo {
pub name: String,
pub terms: Vec<String>,
pub slot_labels: Vec<Option<String>>,
}
pub struct Lowered {
pub x: Vec<f64>,
pub y: Vec<f64>,
pub n: usize,
pub p: usize,
pub col_names: Vec<String>,
pub model: ModelSpec,
pub ids: GroupIds,
pub re_groups: Vec<ReGroupInfo>,
pub notes: Vec<crate::Note>,
pub opts: FitOptions,
}
pub fn lower(formula: &str, data: &Table, family: Family) -> Result<Lowered, Error> {
let ast = parse(formula)?;
materialize(&ast, data, family)
}
pub fn materialize(ast: &ParsedFormula, data: &Table, family: Family) -> Result<Lowered, Error> {
let n = data.n;
let y = match data.get(&ast.dependent) {
Some(Column::Numeric(v)) => v.clone(),
Some(Column::Factor { .. }) => {
return Err(Error::ResponseNotNumeric(ast.dependent.clone()))
}
None => return Err(Error::UnknownColumn(ast.dependent.clone())),
};
let mut col_names: Vec<String> = vec!["(Intercept)".to_string()];
let mut cols: Vec<Vec<f64>> = vec![vec![1.0; n]];
let mut numeric_main_col: HashMap<String, ColumnId> = HashMap::new();
let mut factor_main_cols: HashMap<String, Vec<(String, ColumnId)>> = HashMap::new();
for term in &ast.terms {
use super::parse::Term;
match term {
Term::Main { name } => match data.get(name) {
Some(Column::Numeric(v)) => {
numeric_main_col.insert(name.clone(), cols.len() as ColumnId);
col_names.push(name.clone());
cols.push(v.clone());
}
Some(Column::Factor { levels, codes }) => {
let mut dummies = Vec::new();
for (suffix, col) in factor_dummies(name, levels, codes) {
dummies.push((suffix.clone(), cols.len() as ColumnId));
col_names.push(suffix);
cols.push(col);
}
factor_main_cols.insert(name.clone(), dummies);
}
None => return Err(Error::UnknownColumn(name.clone())),
},
Term::Interaction { vars } => {
for (name, col) in interaction_columns(vars, data)? {
col_names.push(name);
cols.push(col);
}
}
}
}
let p = cols.len();
let mut x = vec![0.0; n * p];
for (j, col) in cols.iter().enumerate() {
for (i, &v) in col.iter().enumerate() {
x[i * p + j] = v;
}
}
let xm = faer::MatRef::from_row_major_slice(&x, n, p);
let (model, ids, re_groups, notes) = lower_random_effects(
ast,
data,
family,
n,
xm,
&numeric_main_col,
&factor_main_cols,
)?;
let opts = FitOptions {
target_indices: (0..p as u32).collect(),
..FitOptions::default()
};
Ok(Lowered {
x,
y,
n,
p,
col_names,
model,
ids,
re_groups,
notes,
opts,
})
}
fn sorted_levels_and_codes(labels: &[String]) -> (Vec<String>, Vec<u32>) {
let levels: Vec<String> = labels
.iter()
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let index: HashMap<&str, u32> = levels
.iter()
.enumerate()
.map(|(i, l)| (l.as_str(), i as u32))
.collect();
let codes = labels.iter().map(|l| index[l.as_str()]).collect();
(levels, codes)
}
fn factor_dummies(var: &str, levels: &[String], codes: &[u32]) -> Vec<(String, Vec<f64>)> {
levels
.iter()
.enumerate()
.skip(1) .map(|(li, lvl)| {
let col: Vec<f64> = codes
.iter()
.map(|&c| if c as usize == li { 1.0 } else { 0.0 })
.collect();
(format!("{var}{lvl}"), col)
})
.collect()
}
fn expand_var(name: &str, data: &Table) -> Result<Vec<(String, Vec<f64>)>, Error> {
match data.get(name) {
Some(Column::Numeric(v)) => Ok(vec![(name.to_string(), v.clone())]),
Some(Column::Factor { levels, codes }) => Ok(factor_dummies(name, levels, codes)),
None => Err(Error::UnknownColumn(name.to_string())),
}
}
fn interaction_columns(vars: &[String], data: &Table) -> Result<Vec<(String, Vec<f64>)>, Error> {
let mut acc = expand_var(&vars[0], data)?;
for var in &vars[1..] {
let next = expand_var(var, data)?;
let mut out = Vec::with_capacity(acc.len() * next.len());
for (n2, c2) in &next {
for (n1, c1) in &acc {
let col: Vec<f64> = c1.iter().zip(c2).map(|(a, b)| a * b).collect();
out.push((format!("{n1}:{n2}"), col));
}
}
acc = out;
}
Ok(acc)
}
fn grouping_factor<'a>(name: &str, data: &'a Table) -> Result<(&'a [String], &'a [u32]), Error> {
match data.get(name) {
Some(Column::Factor { levels, codes }) => Ok((levels, codes)),
Some(Column::Numeric(_)) => Err(Error::WrongColumnKind {
name: name.to_string(),
expected: "a factor (grouping variable)",
}),
None => Err(Error::UnknownColumn(name.to_string())),
}
}
fn grouping_row_labels(name: &str, data: &Table) -> Result<Vec<String>, Error> {
let (levels, codes) = grouping_factor(name, data)?;
Ok(codes.iter().map(|&c| levels[c as usize].clone()).collect())
}
fn nested_padded_ids(parent_ids: &[u32], labels: &[String]) -> (Vec<u32>, Vec<Option<String>>) {
let n_parents = parent_ids
.iter()
.copied()
.max()
.map(|m| m as usize + 1)
.unwrap_or(1);
let mut children_per_parent: Vec<BTreeSet<&str>> = vec![BTreeSet::new(); n_parents];
for (&p, c) in parent_ids.iter().zip(labels) {
children_per_parent[p as usize].insert(c.as_str());
}
let n_per_parent = children_per_parent
.iter()
.map(BTreeSet::len)
.max()
.unwrap_or(1)
.max(1);
let local_index: Vec<HashMap<&str, u32>> = children_per_parent
.iter()
.map(|set| {
set.iter()
.enumerate()
.map(|(i, &l)| (l, i as u32))
.collect()
})
.collect();
let mut slot_labels: Vec<Option<String>> = vec![None; n_parents * n_per_parent];
for (p, set) in children_per_parent.iter().enumerate() {
for (k, &child) in set.iter().enumerate() {
slot_labels[p * n_per_parent + k] = Some(child.to_string());
}
}
let ids = parent_ids
.iter()
.zip(labels)
.map(|(&p, c)| p * n_per_parent as u32 + local_index[p as usize][c.as_str()])
.collect();
(ids, slot_labels)
}
const NESTING_INFLATION_BOUND: usize = 2;
fn detect_flat_nesting(
primary_ids: &[u32],
child_labels: &[String],
) -> Option<(Vec<u32>, Vec<Option<String>>)> {
let n_parents = primary_ids
.iter()
.copied()
.max()
.map(|m| m as usize + 1)
.unwrap_or(1);
let mut parent_of: HashMap<&str, u32> = HashMap::new();
let mut children_per_parent = vec![0usize; n_parents];
for (&p, c) in primary_ids.iter().zip(child_labels) {
match parent_of.insert(c.as_str(), p) {
Some(prev) if prev != p => return None, Some(_) => {} None => children_per_parent[p as usize] += 1, }
}
let w = children_per_parent.iter().copied().max().unwrap_or(0);
if n_parents * w > NESTING_INFLATION_BOUND * parent_of.len() {
return None; }
Some(nested_padded_ids(primary_ids, child_labels))
}
struct GroupingLayout {
ids: Vec<u32>,
slot_labels: Vec<Option<String>>,
unused: Vec<String>,
}
impl GroupingLayout {
fn all_observed(ids: Vec<u32>, labels: Vec<String>) -> Self {
GroupingLayout {
ids,
slot_labels: labels.into_iter().map(Some).collect(),
unused: Vec::new(),
}
}
}
fn grouping_ids(re: &RandomEffect, data: &Table) -> Result<GroupingLayout, Error> {
match re {
RandomEffect::Intercept {
group,
parent: Some(parent),
} => {
let child = group.strip_prefix(&format!("{parent}:")).unwrap_or(group);
let (parent_levels, parent_ids) = grouping_factor(parent, data)?;
let (ids, child_labels) =
nested_padded_ids(parent_ids, &grouping_row_labels(child, data)?);
let n_parents = parent_ids
.iter()
.copied()
.max()
.map(|m| m as usize + 1)
.unwrap_or(1);
let w = (child_labels.len() / n_parents).max(1);
let slot_labels = child_labels
.into_iter()
.enumerate()
.map(|(slot, c)| c.map(|c| format!("{}:{c}", parent_levels[slot / w])))
.collect();
Ok(GroupingLayout {
ids,
slot_labels,
unused: Vec::new(),
})
}
RandomEffect::Intercept {
group,
parent: None,
} if group.contains(':') => {
let (lhs, rhs) = group
.split_once(':')
.expect("group contains ':' per guard above");
let a = grouping_row_labels(lhs, data)?;
let b = grouping_row_labels(rhs, data)?;
let joined: Vec<String> = a.iter().zip(&b).map(|(x, y)| format!("{x}:{y}")).collect();
let (levels, codes) = sorted_levels_and_codes(&joined);
Ok(GroupingLayout::all_observed(codes, levels))
}
RandomEffect::Intercept { group, .. } | RandomEffect::Slope { group, .. } => {
let (levels, codes) = grouping_factor(group, data)?;
let width = codes
.iter()
.copied()
.max()
.map(|m| m as usize + 1)
.unwrap_or(0);
let mut observed = vec![false; width];
for &c in codes {
observed[c as usize] = true;
}
Ok(GroupingLayout {
ids: codes.to_vec(),
slot_labels: levels[..width].iter().cloned().map(Some).collect(),
unused: (0..width)
.filter(|&l| !observed[l])
.map(|l| levels[l].clone())
.collect(),
})
}
}
}
fn slope_cols(
re: &RandomEffect,
numeric_main_col: &HashMap<String, ColumnId>,
factor_main_cols: &HashMap<String, Vec<(String, ColumnId)>>,
) -> Result<Vec<ColumnId>, Error> {
match re {
RandomEffect::Slope { vars, .. } => {
let mut out = Vec::new();
for v in vars {
if let Some(&cid) = numeric_main_col.get(v) {
out.push(cid);
} else if let Some(dummies) = factor_main_cols.get(v) {
out.extend(dummies.iter().map(|(_, cid)| *cid));
} else {
return Err(Error::SlopeVarNotInDesign(v.clone()));
}
}
Ok(out)
}
RandomEffect::Intercept { .. } => Ok(Vec::new()),
}
}
fn re_group_info(
re: &RandomEffect,
factor_main_cols: &HashMap<String, Vec<(String, ColumnId)>>,
slot_labels: Vec<Option<String>>,
) -> ReGroupInfo {
match re {
RandomEffect::Intercept { group, .. } => ReGroupInfo {
name: group.clone(),
terms: vec!["(Intercept)".to_string()],
slot_labels,
},
RandomEffect::Slope { group, vars } => {
let mut terms = vec!["(Intercept)".to_string()];
for v in vars {
if let Some(dummies) = factor_main_cols.get(v) {
terms.extend(dummies.iter().map(|(name, _)| name.clone()));
} else {
terms.push(v.clone());
}
}
ReGroupInfo {
name: group.clone(),
terms,
slot_labels,
}
}
}
}
fn unused_levels_note(name: &str, unused: Vec<String>) -> Option<crate::Note> {
(!unused.is_empty()).then(|| crate::Note::UnusedGroupingLevels {
grouping: name.to_string(),
levels: unused,
})
}
const RE_SCALE_SPREAD_WARN: f64 = 1e3;
fn scale_spread_note(
name: &str,
x: faer::MatRef<'_, f64>,
slope_cols: &[ColumnId],
) -> Option<crate::Note> {
if slope_cols.is_empty() {
return None;
}
let mut lo = 1.0f64;
let mut hi = 1.0f64;
for &c in slope_cols {
let s = crate::lmm::rms_column_scale(x, c as usize, None);
lo = lo.min(s);
hi = hi.max(s);
}
let ratio = hi / lo;
(ratio > RE_SCALE_SPREAD_WARN).then(|| crate::Note::ReDesignScaleSpread {
grouping: name.to_string(),
ratio,
})
}
fn lower_random_effects(
ast: &ParsedFormula,
data: &Table,
family: Family,
n: usize,
xm: faer::MatRef<'_, f64>,
numeric_main_col: &HashMap<String, ColumnId>,
factor_main_cols: &HashMap<String, Vec<(String, ColumnId)>>,
) -> Result<(ModelSpec, GroupIds, Vec<ReGroupInfo>, Vec<crate::Note>), Error> {
if ast.random_effects.is_empty() {
return Ok((
ModelSpec { family, re: None },
GroupIds::default(),
Vec::new(),
Vec::new(),
));
}
let re0 = &ast.random_effects[0];
let primary_slopes = slope_cols(re0, numeric_main_col, factor_main_cols)?;
let primary_layout = grouping_ids(re0, data)?;
let primary_ids = primary_layout.ids;
debug_assert_eq!(primary_ids.len(), n);
let mut extra_groupings = Vec::new();
let mut extra_ids = Vec::new();
let mut notes: Vec<crate::Note> =
unused_levels_note(&re_group_name(re0), primary_layout.unused)
.into_iter()
.chain(scale_spread_note(&re_group_name(re0), xm, &primary_slopes))
.collect();
let mut re_groups = vec![re_group_info(
re0,
factor_main_cols,
primary_layout.slot_labels,
)];
let mut have_nested = false;
for re in &ast.random_effects[1..] {
let slopes = slope_cols(re, numeric_main_col, factor_main_cols)?;
let (relation, layout) = match re {
RandomEffect::Intercept {
parent: Some(_), ..
} => (
GroupingRelation::NestedWithin { n_per_parent: 1 },
grouping_ids(re, data)?,
),
RandomEffect::Intercept {
group,
parent: None,
} if !group.contains(':') && !have_nested => {
match detect_flat_nesting(&primary_ids, &grouping_row_labels(group, data)?) {
Some((padded, slot_labels)) => (
GroupingRelation::NestedWithin { n_per_parent: 1 },
GroupingLayout {
ids: padded,
slot_labels,
unused: Vec::new(),
},
),
None => (
GroupingRelation::Crossed { n_clusters: 1 },
grouping_ids(re, data)?,
),
}
}
_ => (
GroupingRelation::Crossed { n_clusters: 1 },
grouping_ids(re, data)?,
),
};
have_nested |= matches!(relation, GroupingRelation::NestedWithin { .. });
notes.extend(unused_levels_note(&re_group_name(re), layout.unused));
notes.extend(scale_spread_note(&re_group_name(re), xm, &slopes));
extra_groupings.push(Grouping { relation, slopes });
extra_ids.push(layout.ids);
re_groups.push(re_group_info(re, factor_main_cols, layout.slot_labels));
}
let re = ReStructure {
sizing: Sizing::FixedClusters { n_clusters: 1 }, slopes: primary_slopes,
extra_groupings,
};
let ids = GroupIds {
primary: primary_ids,
extra: extra_ids,
};
Ok((
ModelSpec {
family,
re: Some(re),
},
ids,
re_groups,
notes,
))
}
pub struct RanefBlock {
pub group: String,
pub terms: Vec<String>,
pub levels: Vec<String>,
pub values: Vec<f64>,
}
pub fn label_ranef(fit: &crate::Fit, re_groups: &[ReGroupInfo]) -> Result<Vec<RanefBlock>, Error> {
if fit.ranef.is_empty() {
return Ok(Vec::new());
}
let mismatch = |what: &str| Error::RanefShapeMismatch(what.to_string());
if fit.ranef_levels.len() != re_groups.len() {
return Err(mismatch(&format!(
"fit has {} grouping(s), the formula lowered {}",
fit.ranef_levels.len(),
re_groups.len()
)));
}
let total: usize = fit
.ranef_levels
.iter()
.zip(re_groups)
.map(|(&l, g)| l * g.terms.len())
.sum();
if total != fit.ranef.len() {
return Err(mismatch(&format!(
"ranef holds {} value(s), the lowered blocks span {total}",
fit.ranef.len()
)));
}
let mut out = Vec::with_capacity(re_groups.len());
let mut base = 0usize;
for (g, info) in re_groups.iter().enumerate() {
let n_levels = fit.ranef_levels[g];
let q = info.terms.len();
if info.slot_labels.len() != n_levels {
return Err(mismatch(&format!(
"grouping {:?} has {} slot label(s) for {n_levels} level(s)",
info.name,
info.slot_labels.len()
)));
}
let mut levels = Vec::new();
let mut values = Vec::new();
for (l, label) in info.slot_labels.iter().enumerate() {
let Some(label) = label else { continue };
levels.push(label.clone());
values.extend_from_slice(&fit.ranef[base + l * q..base + (l + 1) * q]);
}
out.push(RanefBlock {
group: info.name.clone(),
terms: info.terms.clone(),
levels,
values,
});
base += n_levels * q;
}
Ok(out)
}
fn re_group_name(re: &RandomEffect) -> String {
match re {
RandomEffect::Intercept { group, .. } | RandomEffect::Slope { group, .. } => group.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formula::parse::RandomEffect;
#[test]
fn grouping_ids_nested_unbalanced_pads_to_max_per_parent() {
let table = Table {
columns: vec![
(
"g1".into(),
Column::factor_from_labels(&strs(&["A", "B", "B", "C", "C", "C"])),
),
(
"g2".into(),
Column::factor_from_labels(&strs(&["c1", "c1", "c2", "c1", "c2", "c3"])),
),
],
n: 6,
};
let re = RandomEffect::Intercept {
group: "g1:g2".to_string(),
parent: Some("g1".to_string()),
};
let layout = grouping_ids(&re, &table).unwrap();
assert_eq!(layout.ids, vec![0, 3, 4, 6, 7, 8]);
assert_eq!(
layout.slot_labels,
vec![
Some("A:c1".into()),
None,
None,
Some("B:c1".into()),
Some("B:c2".into()),
None,
Some("C:c1".into()),
Some("C:c2".into()),
Some("C:c3".into()),
]
);
}
fn strs(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn detect_flat_nesting_balanced_is_nested() {
let primary = vec![0, 0, 1, 1];
let child = strs(&["a", "b", "c", "d"]);
let (ids, labels) = detect_flat_nesting(&primary, &child).expect("nesting detected");
assert_eq!(ids, vec![0, 1, 2, 3]);
assert_eq!(
labels,
vec![
Some("a".into()),
Some("b".into()),
Some("c".into()),
Some("d".into())
]
);
}
#[test]
fn detect_flat_nesting_shared_child_stays_crossed() {
let primary = vec![0, 1, 0, 1];
let child = strs(&["x", "x", "y", "y"]);
assert_eq!(detect_flat_nesting(&primary, &child), None);
}
#[test]
fn detect_flat_nesting_near_balanced_is_nested() {
let primary = vec![0, 0, 0, 1, 1, 2, 2, 2];
let child = strs(&["a", "b", "c", "d", "e", "f", "g", "h"]);
let (ids, labels) = detect_flat_nesting(&primary, &child).expect("nesting detected");
assert_eq!(ids, vec![0, 1, 2, 3, 4, 6, 7, 8]);
assert_eq!(labels[5], None, "parent 1's third slot is padding");
assert_eq!(labels[4], Some("e".into()));
}
#[test]
fn detect_flat_nesting_high_inflation_stays_crossed() {
let primary = vec![0, 0, 0, 0, 0, 1, 2];
let child = strs(&["a", "b", "c", "d", "e", "f", "g"]);
assert_eq!(detect_flat_nesting(&primary, &child), None);
}
#[test]
fn second_flat_nesting_candidate_stays_crossed() {
let table = Table {
columns: vec![
(
"y".into(),
Column::Numeric(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]),
),
(
"g1".into(),
Column::factor_from_labels(&strs(&["A", "A", "A", "A", "B", "B", "B", "B"])),
),
(
"g2".into(),
Column::factor_from_labels(&strs(&[
"a1", "a1", "a2", "a2", "b1", "b1", "b2", "b2",
])),
),
(
"g3".into(),
Column::factor_from_labels(&strs(&[
"c1", "c1", "c2", "c2", "d1", "d1", "d2", "d2",
])),
),
],
n: 8,
};
let lo = super::lower("y ~ (1|g1) + (1|g2) + (1|g3)", &table, Family::Gaussian).unwrap();
let relations: Vec<_> = lo
.model
.re
.as_ref()
.unwrap()
.extra_groupings
.iter()
.map(|g| g.relation.clone())
.collect();
assert!(matches!(
relations[0],
GroupingRelation::NestedWithin { .. }
));
assert!(matches!(relations[1], GroupingRelation::Crossed { .. }));
}
}