Skip to main content

qualia_core_db/solvers/learning/kg_embedding/
train.rs

1//! Embedding training — the **heavy, run-once** artifact producer (see the module
2//! docs on affordability gating). Stochastic gradient descent with negative sampling:
3//! a *margin ranking* loss for the translational models (TransE, RotatE) and a
4//! *logistic* loss for the bilinear models (DistMult, ComplEx).
5//!
6//! This is the path that must NOT sit on a user's critical path: a capable machine
7//! runs it once and distributes the resulting [`EmbeddingTable`]. The per-triple score
8//! and gradient are kernel-class `DenseLinear`; this CPU reference is always present
9//! and is what a future GPU batch path would be correctness-gated against (§13).
10//!
11//! The RNG is the deterministic LCG shared with the optimisation library, so a given
12//! `seed` reproduces the same table — important for an auditable, distributable
13//! artifact.
14
15use super::score::ScoreModel;
16use super::{EmbeddingTable, KgEmbeddingError};
17use crate::solvers::optimization::metaheuristics::Rng;
18
19/// Training hyper-parameters.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct TrainConfig {
22    pub model: ScoreModel,
23    pub rank: usize,
24    pub epochs: usize,
25    /// Learning rate.
26    pub lr: f64,
27    /// Margin γ for the ranking loss (translational models). Ignored for logistic.
28    pub margin: f64,
29    /// L2 regularisation coefficient (logistic models). Ignored for margin.
30    pub reg: f64,
31    /// Negative samples drawn per positive triple.
32    pub neg_per_pos: usize,
33    pub seed: u64,
34}
35
36impl Default for TrainConfig {
37    fn default() -> Self {
38        Self {
39            model: ScoreModel::TransE { p: 2 },
40            rank: 16,
41            epochs: 100,
42            lr: 0.05,
43            margin: 1.0,
44            reg: 1e-3,
45            neg_per_pos: 2,
46            seed: 1,
47        }
48    }
49}
50
51fn sigmoid(x: f64) -> f64 {
52    1.0 / (1.0 + (-x).exp())
53}
54
55/// Train an embedding table on `triples` (entity/relation indices) with `n_entities`
56/// distinct entities and `n_relations` relations. Returns the trained table, or fails
57/// closed on an empty corpus / inconsistent config.
58pub fn train(
59    triples: &[(usize, usize, usize)],
60    n_entities: usize,
61    n_relations: usize,
62    cfg: TrainConfig,
63) -> Result<EmbeddingTable, KgEmbeddingError> {
64    if triples.is_empty() {
65        return Err(KgEmbeddingError::InsufficientData);
66    }
67    if cfg.rank == 0 || cfg.epochs == 0 {
68        return Err(KgEmbeddingError::InvalidParameters);
69    }
70    // Validate indices up front (fail closed before any work).
71    for &(h, r, t) in triples {
72        if h >= n_entities || t >= n_entities || r >= n_relations {
73            return Err(KgEmbeddingError::IndexOutOfRange);
74        }
75    }
76
77    let mut table = EmbeddingTable::zeros(cfg.model, cfg.rank, n_entities, n_relations)?;
78    let mut rng = Rng(cfg.seed ^ 0x4B47_4D42_4544_4447);
79
80    // Xavier-ish small init in [-s, s].
81    let s = (6.0_f64 / cfg.rank as f64).sqrt();
82    for v in table.entities.iter_mut().chain(table.relations.iter_mut()) {
83        *v = (rng.unit() * 2.0 - 1.0) * s;
84    }
85
86    let is_margin = matches!(cfg.model, ScoreModel::TransE { .. } | ScoreModel::RotatE);
87    let (ed, rd) = cfg.model.dims(cfg.rank);
88
89    // Reusable gradient buffers (no per-step heap churn in the inner loop).
90    let mut ghp = vec![0.0; ed];
91    let mut grp = vec![0.0; rd];
92    let mut gtp = vec![0.0; ed];
93    let mut ghn = vec![0.0; ed];
94    let mut grn = vec![0.0; rd];
95    let mut gtn = vec![0.0; ed];
96
97    for _epoch in 0..cfg.epochs {
98        for &(h, r, t) in triples {
99            // Snapshot positive vectors.
100            let hv = table.entity(h)?.to_vec();
101            let rv = table.relation(r)?.to_vec();
102            let tv = table.entity(t)?.to_vec();
103
104            for _ in 0..cfg.neg_per_pos {
105                // Corrupt head or tail with a random distinct entity.
106                let corrupt_tail = rng.unit() < 0.5;
107                let mut neg = rng.below(n_entities);
108                let avoid = if corrupt_tail { t } else { h };
109                if neg == avoid {
110                    neg = (neg + 1) % n_entities;
111                }
112                let (nh, nt) = if corrupt_tail { (h, neg) } else { (neg, t) };
113                let nhv = table.entity(nh)?.to_vec();
114                let ntv = table.entity(nt)?.to_vec();
115
116                let score_pos = cfg.model.score(&hv, &rv, &tv, cfg.rank)?;
117                let score_neg = cfg.model.score(&nhv, &rv, &ntv, cfg.rank)?;
118
119                if is_margin {
120                    // L = max(0, margin - score_pos + score_neg). score = -distance.
121                    let loss = cfg.margin - score_pos + score_neg;
122                    if loss > 0.0 {
123                        cfg.model
124                            .gradient(&hv, &rv, &tv, cfg.rank, &mut ghp, &mut grp, &mut gtp)?;
125                        cfg.model
126                            .gradient(&nhv, &rv, &ntv, cfg.rank, &mut ghn, &mut grn, &mut gtn)?;
127                        // Ascend score_pos, descend score_neg.
128                        apply(table.entity_mut(h), &ghp, cfg.lr);
129                        apply(table.entity_mut(t), &gtp, cfg.lr);
130                        apply(table.relation_mut(r), &grp, cfg.lr);
131                        apply(table.entity_mut(nh), &ghn, -cfg.lr);
132                        apply(table.entity_mut(nt), &gtn, -cfg.lr);
133                        apply(table.relation_mut(r), &grn, -cfg.lr);
134                    }
135                } else {
136                    // Logistic: param -= lr*(∂L/∂s * grad + reg*param), ∂L/∂s = -y·σ(-y·s).
137                    // Positive (y=+1).
138                    cfg.model
139                        .gradient(&hv, &rv, &tv, cfg.rank, &mut ghp, &mut grp, &mut gtp)?;
140                    let cp = -sigmoid(-score_pos);
141                    apply_reg(table.entity_mut(h), &ghp, cfg.lr, cp, cfg.reg);
142                    apply_reg(table.entity_mut(t), &gtp, cfg.lr, cp, cfg.reg);
143                    apply_reg(table.relation_mut(r), &grp, cfg.lr, cp, cfg.reg);
144                    // Negative (y=-1).
145                    cfg.model
146                        .gradient(&nhv, &rv, &ntv, cfg.rank, &mut ghn, &mut grn, &mut gtn)?;
147                    let cn = sigmoid(score_neg);
148                    apply_reg(table.entity_mut(nh), &ghn, cfg.lr, cn, cfg.reg);
149                    apply_reg(table.entity_mut(nt), &gtn, cfg.lr, cn, cfg.reg);
150                    apply_reg(table.relation_mut(r), &grn, cfg.lr, cn, cfg.reg);
151                }
152            }
153
154            // TransE/RotatE: renormalise entity embeddings to unit L2 (standard).
155            if is_margin {
156                normalise(table.entity_mut(h));
157                normalise(table.entity_mut(t));
158            }
159        }
160    }
161
162    Ok(table)
163}
164
165/// `param += lr * grad` (gradient ascent on a score).
166fn apply(param: &mut [f64], grad: &[f64], lr: f64) {
167    for (p, &g) in param.iter_mut().zip(grad) {
168        *p += lr * g;
169    }
170}
171
172/// `param -= lr * (coeff*grad + reg*param)` (logistic descent with L2).
173fn apply_reg(param: &mut [f64], grad: &[f64], lr: f64, coeff: f64, reg: f64) {
174    for (p, &g) in param.iter_mut().zip(grad) {
175        *p -= lr * (coeff * g + reg * *p);
176    }
177}
178
179fn normalise(v: &mut [f64]) {
180    let n: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
181    if n > 1e-12 {
182        for x in v.iter_mut() {
183            *x /= n;
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::super::predict::{rank_tail, RankFilter};
191    use super::*;
192
193    /// A consistent chain under one relation: 0→1→2→3. A working translational model
194    /// must learn to rank the true tail first.
195    fn chain() -> Vec<(usize, usize, usize)> {
196        vec![(0, 0, 1), (1, 0, 2), (2, 0, 3)]
197    }
198
199    fn mean_pos_minus_neg(
200        table: &EmbeddingTable,
201        pos: &[(usize, usize, usize)],
202        neg: &[(usize, usize, usize)],
203    ) -> f64 {
204        let mp: f64 = pos
205            .iter()
206            .map(|&(h, r, t)| table.score(h, r, t).unwrap())
207            .sum::<f64>()
208            / pos.len() as f64;
209        let mn: f64 = neg
210            .iter()
211            .map(|&(h, r, t)| table.score(h, r, t).unwrap())
212            .sum::<f64>()
213            / neg.len() as f64;
214        mp - mn
215    }
216
217    #[test]
218    fn transe_learns_to_rank_true_tail_first() {
219        // Two disjoint edges under one relation — a pattern TransE *can* represent
220        // under unit-norm entities (a single shared translation offset). A length-3
221        // chain on the unit sphere is the classic TransE representability limit, so we
222        // avoid it here.
223        let triples = vec![(0, 0, 1), (2, 0, 3)];
224        let cfg = TrainConfig {
225            model: ScoreModel::TransE { p: 2 },
226            rank: 8,
227            epochs: 500,
228            lr: 0.05,
229            margin: 1.0,
230            reg: 0.0,
231            neg_per_pos: 4,
232            seed: 7,
233        };
234        let table = train(&triples, 4, 1, cfg).unwrap();
235        // Each edge's true tail should rank first among all entities.
236        assert_eq!(
237            rank_tail(&table, 0, 0, 1, &[0, 1, 2, 3], RankFilter::Raw).unwrap(),
238            1
239        );
240        assert_eq!(
241            rank_tail(&table, 2, 0, 3, &[0, 1, 2, 3], RankFilter::Raw).unwrap(),
242            1
243        );
244    }
245
246    #[test]
247    fn distmult_separates_positives_from_negatives() {
248        // Symmetric "sibling" relation — DistMult models symmetry well.
249        let pos = vec![(0, 0, 1), (1, 0, 0), (2, 0, 3), (3, 0, 2)];
250        let neg = vec![(0, 0, 2), (1, 0, 3), (0, 0, 3)];
251        let cfg = TrainConfig {
252            model: ScoreModel::DistMult,
253            rank: 8,
254            epochs: 400,
255            lr: 0.1,
256            margin: 0.0,
257            reg: 1e-3,
258            neg_per_pos: 4,
259            seed: 3,
260        };
261        let table = train(&pos, 4, 1, cfg).unwrap();
262        let gap = mean_pos_minus_neg(&table, &pos, &neg);
263        assert!(
264            gap > 0.5,
265            "DistMult positives not separated from negatives (gap {gap})"
266        );
267    }
268
269    #[test]
270    fn complex_separates_positives() {
271        let pos = vec![(0, 0, 1), (1, 0, 2), (2, 0, 0)]; // cyclic
272        let neg = vec![(0, 0, 2), (1, 0, 0), (2, 0, 1)];
273        let cfg = TrainConfig {
274            model: ScoreModel::ComplEx,
275            rank: 8,
276            epochs: 500,
277            lr: 0.1,
278            margin: 0.0,
279            reg: 1e-3,
280            neg_per_pos: 4,
281            seed: 5,
282        };
283        let table = train(&pos, 3, 1, cfg).unwrap();
284        let gap = mean_pos_minus_neg(&table, &pos, &neg);
285        assert!(gap > 0.3, "ComplEx positives not separated (gap {gap})");
286    }
287
288    #[test]
289    fn rotate_learns_a_chain() {
290        let triples = chain();
291        let cfg = TrainConfig {
292            model: ScoreModel::RotatE,
293            rank: 8,
294            epochs: 500,
295            lr: 0.05,
296            margin: 1.0,
297            reg: 0.0,
298            neg_per_pos: 4,
299            seed: 11,
300        };
301        let table = train(&triples, 4, 1, cfg).unwrap();
302        // Positive (0,0,1) should outscore a corrupted (0,0,3).
303        assert!(table.score(0, 0, 1).unwrap() > table.score(0, 0, 3).unwrap());
304    }
305
306    #[test]
307    fn empty_corpus_fails_closed() {
308        assert_eq!(
309            train(&[], 4, 1, TrainConfig::default()).unwrap_err(),
310            KgEmbeddingError::InsufficientData
311        );
312    }
313
314    #[test]
315    fn out_of_range_index_fails_closed() {
316        let bad = vec![(0, 0, 9)];
317        assert_eq!(
318            train(&bad, 4, 1, TrainConfig::default()).unwrap_err(),
319            KgEmbeddingError::IndexOutOfRange
320        );
321    }
322}