use hessboost::config::BoosterKind;
use hessboost::data::{CsvOptions, load_csv};
use hessboost::metric::EvalMetric;
use hessboost::objective::RegLoss;
use hessboost::prelude::*;
use std::num::NonZeroUsize;
use std::path::Path;
mod common;
use common::{fill_random, lcg};
const SCALES: [f32; 8] = [0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0];
const MAX_ROUNDS: usize = 2000;
const EARLY_STOPPING: usize = 30;
struct Split {
data: DMatrix,
prior: Vec<f32>,
}
impl Split {
fn labels(&self) -> &[f32] {
self.data.labels().expect("splits are built with labels")
}
fn with_prior(&self, s: f32, c: f32) -> Result<DMatrix> {
let margin: Vec<f32> = self.prior.iter().map(|&p| s * p + c).collect();
self.data.clone().with_base_margin(&margin)
}
}
#[derive(Clone, Copy)]
struct Score {
logloss: f64,
auc: f64,
}
fn score(p: &[f32], y: &[f32]) -> Result<Score> {
Ok(Score {
logloss: EvalMetric::LogLoss.build(1)?.eval(p, y, None),
auc: EvalMetric::Auc.build(1)?.eval(p, y, None),
})
}
fn sigmoid(z: f32) -> f32 {
1.0 / (1.0 + (-z).exp())
}
struct Comparison {
prior: Score,
scratch: Score,
scratch_trees: usize,
boosted: Score,
boosted_trees: usize,
scale: f32,
forgot_prior: Score,
}
fn params() -> Result<TrainingParams> {
TrainingParams::builder()
.objective(Objective::BinaryLogistic(RegLoss::default()))
.eval_metric(EvalMetric::LogLoss)
.max_depth(3)
.eta(0.05)
.build()
}
fn used_rounds(model: &BoostedModel) -> usize {
model
.best_iteration()
.map_or(model.num_trees(), |it| it + 1)
}
fn compare(train: &Split, valid: &Split, test: &Split) -> Result<Comparison> {
let params = params()?;
let prior = score(
&test.prior.iter().map(|&z| sigmoid(z)).collect::<Vec<_>>(),
test.labels(),
)?;
let scratch = Trainer::new(¶ms, &train.data, MAX_ROUNDS)
.eval(&valid.data, "valid")
.early_stopping_rounds(NonZeroUsize::new(EARLY_STOPPING).unwrap())
.train()?
.model;
let prior_mean =
train.prior.iter().map(|&p| f64::from(p)).sum::<f64>() / train.prior.len() as f64;
let mut best: Option<(f64, f32, f32, BoostedModel)> = None;
for s in SCALES {
let c = scratch.base_score() - s * prior_mean as f32;
let dtrain = train.with_prior(s, c)?;
let dvalid = valid.with_prior(s, c)?;
let model = Trainer::new(¶ms, &dtrain, MAX_ROUNDS)
.eval(&dvalid, "valid")
.early_stopping_rounds(NonZeroUsize::new(EARLY_STOPPING).unwrap())
.train()?
.model;
let valid_loss = EvalMetric::LogLoss.build(1)?.eval(
model.predict(&dvalid, Iterations::Best)?.as_slice(),
valid.labels(),
None,
);
if best.as_ref().is_none_or(|(loss, ..)| valid_loss < *loss) {
best = Some((valid_loss, s, c, model));
}
}
let (_, scale, c, boosted) = best.expect("SCALES is non-empty");
let dtest = test.with_prior(scale, c)?;
Ok(Comparison {
prior,
scratch: score(
scratch.predict(&test.data, Iterations::Best)?.as_slice(),
test.labels(),
)?,
scratch_trees: used_rounds(&scratch),
boosted: score(
boosted.predict(&dtest, Iterations::Best)?.as_slice(),
test.labels(),
)?,
boosted_trees: used_rounds(&boosted),
scale,
forgot_prior: score(
boosted.predict(&test.data, Iterations::Best)?.as_slice(),
test.labels(),
)?,
})
}
fn print_header() {
println!(
"{:>7} | {:>15} | {:>21} | {:>27} | {:>11}",
"n_train", "prior alone", "from scratch", "PFN-Boost (s chosen)", "no margin*"
);
println!(
"{:>7} | {:>7} {:>7} | {:>7} {:>7} {:>5} | {:>7} {:>7} {:>5} {:>5} | {:>11}",
"", "logloss", "AUC", "logloss", "AUC", "trees", "logloss", "AUC", "trees", "s", "logloss"
);
}
fn print_row(n_train: usize, r: &Comparison) {
println!(
"{:>7} | {:>7.4} {:>7.4} | {:>7.4} {:>7.4} {:>5} | {:>7.4} {:>7.4} {:>5} {:>5} | {:>11.4}",
n_train,
r.prior.logloss,
r.prior.auc,
r.scratch.logloss,
r.scratch.auc,
r.scratch_trees,
r.boosted.logloss,
r.boosted.auc,
r.boosted_trees,
r.scale,
r.forgot_prior.logloss,
);
}
fn print_footer() {
println!(
"\n* the PFN-Boost model predicting a test matrix built WITHOUT its base_margin: \
rows start from the intercept, so the prior is silently lost."
);
}
const N_FEATURES: usize = 6;
fn target_logit(x: &[f32]) -> f32 {
let bump = if x[3] > 0.5 && x[4] > 0.5 { 2.5 } else { 0.0 };
4.0 * (x[0] - 0.5) - 3.0 * (x[1] - 0.5) + 2.0 * (x[2] - 0.5) + bump - 0.8
}
fn pretraining_logit(x: &[f32]) -> f32 {
3.5 * (x[0] - 0.5) - 3.0 * (x[1] - 0.5) + 1.5 * (x[2] - 0.5)
}
fn sample(n: usize, seed: u64, logit: fn(&[f32]) -> f32) -> Result<DMatrix> {
let mut rng = lcg(seed);
let mut x = vec![0f32; n * N_FEATURES];
let mut y = vec![0f32; n];
for (row, label) in x.as_chunks_mut::<N_FEATURES>().0.iter_mut().zip(&mut y) {
fill_random(&mut rng, row);
*label = f32::from(u8::from(rng() < sigmoid(logit(row))));
}
DMatrix::from_dense(&x, n, N_FEATURES)?.with_labels(&y)
}
fn head(data: &DMatrix, n: usize) -> Result<DMatrix> {
data.select_rows(&(0..n).collect::<Vec<_>>())
}
fn run_synthetic() -> Result<()> {
let pretraining = sample(20_000, 7, pretraining_logit)?;
let prior_params = TrainingParams::builder()
.objective(Objective::BinaryLogistic(RegLoss::default()))
.booster(BoosterKind::GbLinear)
.eta(0.5)
.build()?;
let prior_model = train(&prior_params, &pretraining, 100)?;
let as_split = |data: DMatrix| -> Result<Split> {
let prior = prior_model
.predict_margin(&data, Iterations::Best)?
.into_vec();
Ok(Split { data, prior })
};
println!(
"stand-in prior: logistic model fit on {} pretraining rows of a related task",
pretraining.n_rows()
);
println!("test set: 20000 rows of the target task; train/valid = 80/20 of n_train\n");
let test = as_split(sample(20_000, 11, target_logit)?)?;
let pool = sample(8_000, 13, target_logit)?;
print_header();
for n_train in [50, 100, 250, 1000, 4000, 8000] {
let data = head(&pool, n_train)?;
let n_fit = n_train * 4 / 5;
let train = as_split(head(&data, n_fit)?)?;
let valid = as_split(data.select_rows(&(n_fit..n_train).collect::<Vec<_>>())?)?;
let r = compare(&train, &valid, &test)?;
print_row(n_train, &r);
}
print_footer();
Ok(())
}
fn read_prior(path: &Path) -> Result<Vec<f32>> {
let text = std::fs::read_to_string(path)?;
text.lines()
.skip(1)
.filter(|line| !line.trim().is_empty())
.map(|line| {
line.trim().parse::<f32>().map_err(|e| {
HessboostError::invalid_param(
"prior",
format!("{}: `{line}` is not a number ({e})", path.display()),
)
})
})
.collect()
}
fn load_split(dir: &Path, name: &str) -> Result<Split> {
let data = load_csv(dir.join(format!("{name}.csv")), &CsvOptions::default())?;
let prior = read_prior(&dir.join(format!("{name}_prior.csv")))?;
if prior.len() != data.n_rows() {
return Err(HessboostError::DimensionMismatch {
what: "prior rows",
expected: data.n_rows(),
got: prior.len(),
});
}
Ok(Split { data, prior })
}
fn run_csv(dir: &Path) -> Result<()> {
let train = load_split(dir, "train")?;
let valid = load_split(dir, "valid")?;
let test = load_split(dir, "test")?;
println!(
"prior scores from {}: {} train / {} valid / {} test rows\n",
dir.display(),
train.data.n_rows(),
valid.data.n_rows(),
test.data.n_rows()
);
let r = compare(&train, &valid, &test)?;
print_header();
print_row(train.data.n_rows() + valid.data.n_rows(), &r);
print_footer();
Ok(())
}
fn main() -> Result<()> {
match std::env::args_os().nth(1) {
Some(dir) => run_csv(Path::new(&dir)),
None => run_synthetic(),
}
}