fingerprint
Feature importance says how much a model relies on a feature. It does not say how. A feature the model uses as a straight line and one it uses as a U-shape can have the same importance and call for very different amounts of trust: the line probably reflects something about the market, the U may reflect six observations in the tails. The model fingerprint of Li, Turkington and Yazdani (2020) separates the two, and adds a third part for pairs of features that only matter together. It is not from AFML; the implementation is a port of mlfinlab’s.
It needs nothing from the model but predictions, so it works on anything: implement
RegressionPredictor::predict or ClassificationPredictor::predict_proba (both take rows
and return one number per row).
The decomposition
Section titled “The decomposition”Everything is built on the partial dependence function (Friedman, 2001). For feature and a value , set column to in every row of the data, predict, and average:
fit evaluates this at num_values quantiles of each feature, from its minimum to its
maximum. Let be the least-squares line through those points and
their mean. Then, averaging over the grid,
The linear effect is how far the fitted line moves the prediction; the non-linear effect is what the line leaves unexplained. For a pair , the joint partial dependence is computed on the full grid of both features, and the pairwise effect is the mean absolute part of it that the two single-feature curves cannot account for:
All three are in the units of the prediction, so they can be compared with each other.
Effect::raw holds them; Effect::norm rescales each family to sum to 1.
On a model whose answer is known
Section titled “On a model whose answer is known”The model below is a formula, so the right decomposition can be read off it: is linear, is purely non-linear on a symmetric feature, and is a pure interaction.
use openquant::fingerprint::{RegressionModelFingerprint, RegressionPredictor};
struct Known;impl RegressionPredictor for Known { fn predict(&self, x: &[Vec<f64>]) -> Vec<f64> { x.iter().map(|r| 2.0 * r[0] + r[1] * r[1] + r[0] * r[2]).collect() }}
// Reproducible noise in [-1, 1) from a hash, so the example needs no RNG.fn noise(i: usize, salt: u64) -> f64 { let mut h = (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ salt.wrapping_mul(0xD1B5_4A32_D192_ED03); h ^= h >> 31; h = h.wrapping_mul(0x7FB5_D329_728E_A185); h ^= h >> 27; (h >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0}
let x: Vec<Vec<f64>> = (0..600).map(|i| vec![noise(i, 1), noise(i, 2), noise(i, 3)]).collect();
let mut fingerprint = RegressionModelFingerprint::new();fingerprint.fit(&Known, &x, 20, Some(&[(0, 2), (0, 1)]))?;let (linear, non_linear, pairwise) = fingerprint.get_effects()?;let pairwise = pairwise.expect("pairs were requested");
// x0 carries the linear effect: mean |2v| over [-1, 1] is about 1.assert!((linear.raw[&0] - 1.058).abs() < 1e-3);assert!(linear.norm[&0] > 0.98);// x1 is the only non-linear feature; its linear effect is nil because x1^2 is symmetric.assert!(non_linear.norm[&1] > 0.999);assert!(linear.raw[&1] < 0.01);// x2 does nothing alone and everything with x0. Keys are the pair, formatted "(k, l)".assert!(linear.raw[&2] < 0.02 && non_linear.raw[&2] < 1e-12);assert!((pairwise.raw["(0, 2)"] - 0.281).abs() < 1e-3);assert!(pairwise.raw["(0, 1)"] < 1e-12);The third result is the one importance methods miss. has no linear effect and no non-linear effect, and an SFI run on it would score a coin flip. It matters only through , and the pairwise effect of 0.28 — the same size as ‘s non-linear effect — is where that shows up.
What to watch for
Section titled “What to watch for”- Partial dependence assumes features can be varied independently. Setting one column to a value while leaving the others alone creates rows that may never occur: a 5% daily return with bottom-decile volatility. The model is evaluated out of its training distribution and the curve can show behaviour it never exhibits on real data. Correlated features are where the fingerprint is least trustworthy; orthogonalise, or read those curves sceptically.
- Pairs are opt-in and expensive. Single effects cost
features × num_valuesfull predictions over the data; each pair costsnum_values²more. Withnum_values = 50(mlfinlab’s default) that is 2,500 passes per pair. Pass the pairs you have a reason to suspect, not all of them. - The grid runs from the minimum to the maximum. The first and last points are the most extreme observations, so one outlier stretches the grid and the fitted line. Winsorise the features first.
- The linear effect depends on the feature’s range, by design. A feature with a small slope and a wide range can have a larger linear effect than one with a steep slope and a narrow range. That is the right answer to “how much does it move predictions”, and the wrong one to “how sensitive is the model”.
- Classification works on probabilities, so effects are in probability units and are compressed near 0 and 1. A feature that moves log-odds a great deal for confident predictions will look minor.
plot_effectsreturns three summary strings, not a plot. There is no plotting in the Rust crate.
Related modules
Section titled “Related modules”feature-importance— how much each feature matters, to pair with how it matters.feature-diagnostics— importance reports from Python.codependence— find the correlated features whose partial dependence to distrust.
API reference
Section titled “API reference”Rustdoc for this commit, with every public item of openquant::fingerprint (after a crates.io release the same pages are on docs.rs):