1use super::score::ScoreModel;
16use super::{EmbeddingTable, KgEmbeddingError};
17use crate::solvers::optimization::metaheuristics::Rng;
18
19#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct TrainConfig {
22 pub model: ScoreModel,
23 pub rank: usize,
24 pub epochs: usize,
25 pub lr: f64,
27 pub margin: f64,
29 pub reg: f64,
31 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
55pub 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 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 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 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 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 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 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 apply(table.entity_mut(h), &ghp, cfg.lr);
129 apply(table.entity_mut(t), >p, 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), >n, -cfg.lr);
133 apply(table.relation_mut(r), &grn, -cfg.lr);
134 }
135 } else {
136 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), >p, cfg.lr, cp, cfg.reg);
143 apply_reg(table.relation_mut(r), &grp, cfg.lr, cp, cfg.reg);
144 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), >n, cfg.lr, cn, cfg.reg);
150 apply_reg(table.relation_mut(r), &grn, cfg.lr, cn, cfg.reg);
151 }
152 }
153
154 if is_margin {
156 normalise(table.entity_mut(h));
157 normalise(table.entity_mut(t));
158 }
159 }
160 }
161
162 Ok(table)
163}
164
165fn 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
172fn 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 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 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 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 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)]; 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 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}