docs: state what the joint's cost actually scales in
A consumer measured an 8x difference in solve time between two fits over the same events, the same slices and the same ~2,000 nodes: career fit (gamma = 0) 787 ms drifting fit (gamma = 0.15) 6214 ms Entirely the collapse rule. A competitor with zero drift contributes one variable however long the history, so a drift-free fit's joint is smaller than a drifting one's by roughly the slice count — and to factorise, by its cube. Choosing a drift configuration is therefore also choosing a query cost, and nothing said so. Documented on `Joint`, on `Joint::variables` and on `posterior_of`, with the measurement. `variables()` is named as the number that decides affordability, since it can be read before committing to a batch. Also states the thing the consumer proposed as a future optimisation, because it is already true: an absence is not an appearance, so a competitor seen in the first and last of a hundred slices contributes two variables rather than a hundred. The matrix is already as small as the model allows on that axis. tests/joint_handle.rs pins the mechanism — ten slices, two competitors, twenty variables drifting against two at `gamma = 0` — so a change to the collapse rule cannot quietly remove the property the docs now promise. Refs #51 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011hcFjNDmHXZF8URGLku5zZ
This commit is contained in:
+44
-3
@@ -1109,6 +1109,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
/// [`Joint`] with [`History::joint`] instead — the answers are identical,
|
/// [`Joint`] with [`History::joint`] instead — the answers are identical,
|
||||||
/// and only the first one pays.
|
/// and only the first one pays.
|
||||||
///
|
///
|
||||||
|
/// # Cost
|
||||||
|
///
|
||||||
|
/// A dense solve over the history's *appearances*, not its competitors. A
|
||||||
|
/// drift-free competitor collapses to a single variable however long the
|
||||||
|
/// history, so the same events can differ enormously in cost depending on
|
||||||
|
/// the drift configuration — see [`Joint`], which also amortises this
|
||||||
|
/// across many questions.
|
||||||
|
///
|
||||||
/// # Limitations
|
/// # Limitations
|
||||||
///
|
///
|
||||||
/// Exact only for a history whose events are all scored, because a scored
|
/// Exact only for a history whose events are all scored, because a scored
|
||||||
@@ -2205,6 +2213,34 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> History<T, D, O
|
|||||||
/// added while it is alive. That is what makes it correct without any
|
/// added while it is alive. That is what makes it correct without any
|
||||||
/// invalidation logic: there is no window in which the factorisation could
|
/// invalidation logic: there is no window in which the factorisation could
|
||||||
/// describe a fit that no longer exists.
|
/// describe a fit that no longer exists.
|
||||||
|
///
|
||||||
|
/// # What the cost actually scales in
|
||||||
|
///
|
||||||
|
/// Not competitors, and not slices times competitors. One variable per
|
||||||
|
/// *appearance* — a competitor per slice they appear in — minus every
|
||||||
|
/// consecutive pair with no drift between them, which collapse to a single
|
||||||
|
/// latent variable.
|
||||||
|
///
|
||||||
|
/// That last clause dominates, and it is not obvious. A competitor whose drift
|
||||||
|
/// is zero contributes **one** variable however long the history: whole-history
|
||||||
|
/// `gamma = 0`, or `drift_scale = 0` on that competitor. So two fits over the
|
||||||
|
/// same events and the same slices can differ in problem size by roughly the
|
||||||
|
/// slice count, and in factorisation time by its cube. Measured by a consumer
|
||||||
|
/// on a ~2,000-node model over 76 slices:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// career fit (gamma = 0) 787 ms per solve
|
||||||
|
/// drifting fit (gamma = 0.15) 6214 ms per solve
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Choosing between a drifting and a drift-free configuration is therefore also
|
||||||
|
/// choosing an 8x difference in query cost. [`Joint::variables`] reports the
|
||||||
|
/// number that decides it, and can be read before committing to a batch of
|
||||||
|
/// queries.
|
||||||
|
///
|
||||||
|
/// Slices a competitor sits out cost nothing: an absence is not an appearance,
|
||||||
|
/// so a competitor seen in the first and last of a hundred slices contributes
|
||||||
|
/// two variables, not a hundred.
|
||||||
pub struct Joint<'h, T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> {
|
pub struct Joint<'h, T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> {
|
||||||
history: &'h History<T, D, O, K>,
|
history: &'h History<T, D, O, K>,
|
||||||
cholesky: crate::joint::Cholesky,
|
cholesky: crate::joint::Cholesky,
|
||||||
@@ -2232,9 +2268,14 @@ impl<T: Time, D: Drift<T>, O: Observer<T>, K: Eq + Hash + Clone> Joint<'_, T, D,
|
|||||||
/// Number of variables in the joint: the history's appearances, after
|
/// Number of variables in the joint: the history's appearances, after
|
||||||
/// collapsing consecutive pairs a competitor does not drift between.
|
/// collapsing consecutive pairs a competitor does not drift between.
|
||||||
///
|
///
|
||||||
/// This is what the cost scales in, and it is not the competitor count — a
|
/// This is what the cost scales in — `O(n^3)` to factorise, `O(n^2)` per
|
||||||
/// competitor contributes one variable per slice it appears in. Worth
|
/// query — and it is neither the competitor count nor slices times
|
||||||
/// checking before asking for a joint over a long history.
|
/// competitors. A drift-free competitor contributes one variable however
|
||||||
|
/// many slices they appear in; see the type docs for how large that
|
||||||
|
/// difference gets.
|
||||||
|
///
|
||||||
|
/// Worth reading before committing to a batch of queries: it is the one
|
||||||
|
/// number that says whether a joint over this history is affordable.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn variables(&self) -> usize {
|
pub fn variables(&self) -> usize {
|
||||||
self.width
|
self.width
|
||||||
|
|||||||
@@ -141,6 +141,53 @@ fn variables_counts_appearances_not_competitors() {
|
|||||||
assert_eq!(joint.variables(), 12);
|
assert_eq!(joint.variables(), 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How much the collapse is worth, which is the part a caller has to plan
|
||||||
|
/// around: a drift-free competitor contributes **one** variable however long
|
||||||
|
/// the history, so the same events at `gamma = 0` and `gamma > 0` differ by
|
||||||
|
/// roughly the slice count in problem size — and by its cube in solve time.
|
||||||
|
///
|
||||||
|
/// Reported by a consumer as an 8x difference in solve time on a ~2,000-node,
|
||||||
|
/// 76-slice model (787 ms career against 6,214 ms drifting). This pins the
|
||||||
|
/// mechanism behind that so a change to the collapse rule cannot quietly
|
||||||
|
/// remove it.
|
||||||
|
#[test]
|
||||||
|
fn drift_free_competitors_shrink_the_joint_by_the_slice_count() {
|
||||||
|
fn variables(gamma: f64) -> usize {
|
||||||
|
let mut h = History::builder()
|
||||||
|
.mu(0.0)
|
||||||
|
.sigma(6.0)
|
||||||
|
.beta(1.0)
|
||||||
|
.score_sigma(2.0)
|
||||||
|
.drift(ConstantDrift(gamma))
|
||||||
|
.convergence(ConvergenceOptions {
|
||||||
|
max_iter: 20_000,
|
||||||
|
epsilon: 1e-13,
|
||||||
|
alpha: 1.0,
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
h.add_events(
|
||||||
|
(1..=10)
|
||||||
|
.map(|t| duel("a", "b", t, 5.0, 2.0))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let _ = h.converge().unwrap();
|
||||||
|
h.joint().unwrap().variables()
|
||||||
|
}
|
||||||
|
|
||||||
|
let drifting = variables(0.5);
|
||||||
|
let career = variables(0.0);
|
||||||
|
|
||||||
|
// Two competitors over ten slices: twenty appearances, or two variables.
|
||||||
|
assert_eq!(drifting, 20);
|
||||||
|
assert_eq!(career, 2);
|
||||||
|
assert_eq!(
|
||||||
|
drifting / career,
|
||||||
|
10,
|
||||||
|
"collapse should track the slice count"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// With `drift = 0` consecutive appearances are the same latent variable, so
|
/// With `drift = 0` consecutive appearances are the same latent variable, so
|
||||||
/// the joint is smaller than the appearance count.
|
/// the joint is smaller than the appearance count.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user