Skip to main content

steeldb/
relation_train.rs

1//! **Head C: the biaffine relation scorer** — the head that makes dimension 2 real.
2//!
3//! Relation polarity is a property of the *argument side*, not of the predicate token, so it cannot be
4//! produced by token tagging: knowing that "develops" is a `REL` span says nothing about which entity
5//! acts and which is acted upon. Head C binds arguments explicitly. For a predicted pair `(h, t)` with
6//! relation `r` the projector emits `rel/r/+` on `h` and `rel/r/-` on `t`.
7//!
8//! ## Architecture
9//!
10//! ```text
11//!   encoder hidden [T,H] ──span pooling──▶ span reps [S,H']   H' = 2H (start ⊕ mean)
12//!                                              │
13//!                          biaffine:  s(h,t)_r = hᵀ W_r t  +  U_r·[h;t]  +  b_r
14//!                                              │
15//!                                       pair logits [S,S,R+1]
16//!                                              │
17//!                       TYPE MASK from the spec: for facets (f_h, f_t) only relations
18//!                       declared `head=f_h, tail=f_t` are scorable; everything else is −∞
19//! ```
20//!
21//! The type mask is the load-bearing detail. `develops: org → system` means the reversed pairing is
22//! **unrepresentable**, not merely unlikely — the paper's guarded fragment enforced inside the model, and
23//! the reason Head C's output is linter-clean by construction.
24
25use crate::tagger_data::{pair_mask, LabeledSpan, TaggerExample};
26use crate::tagger_train::{hrm_config_from, MultiHeadTagger, TrainConfig};
27use crate::vocabulary::VocabularySpace;
28use candle_core::{DType, Device, IndexOp, Tensor, D};
29use candle_nn::{loss, ops::softmax, AdamW, Linear, Module, Optimizer, ParamsAdamW, VarBuilder, VarMap};
30use serde::Serialize;
31use std::collections::HashSet;
32use tokenizers::Tokenizer;
33
34/// Biaffine pair scorer over pooled span representations.
35pub struct BiaffineHead {
36    /// `[R1, H', H']` — one bilinear matrix per relation class (including `none`)
37    w: Tensor,
38    /// concatenation term `[h;t] → R1`
39    u: Linear,
40    n_rel: usize,
41    hp: usize,
42}
43
44impl BiaffineHead {
45    pub fn new(vb: VarBuilder, hp: usize, n_rel: usize) -> candle_core::Result<Self> {
46        let w = vb.get((n_rel, hp, hp), "biaffine_w")?;
47        let u = candle_nn::linear(2 * hp, n_rel, vb.pp("biaffine_u"))?;
48        Ok(BiaffineHead { w, u, n_rel, hp })
49    }
50
51    /// Score every ordered pair: `spans [S,H'] → logits [S,S,R1]`.
52    pub fn forward(&self, spans: &Tensor) -> candle_core::Result<Tensor> {
53        let (s, hp) = spans.dims2()?;
54        debug_assert_eq!(hp, self.hp);
55        // bilinear term, one relation class at a time: [S,H'] @ W_r [H',H'] @ [H',S] → [S,S]
56        let mut planes: Vec<Tensor> = Vec::with_capacity(self.n_rel);
57        let spans_t = spans.t()?.contiguous()?;
58        for r in 0..self.n_rel {
59            let wr = self.w.i(r)?.contiguous()?;
60            let bil = spans.matmul(&wr)?.matmul(&spans_t)?; // [S,S]
61            planes.push(bil.unsqueeze(2)?); // [S,S,1]
62        }
63        let bilinear = Tensor::cat(&planes, 2)?; // [S,S,R1]
64
65        // concatenation term: broadcast [h;t] over all pairs → [S,S,R1]
66        let h_rep = spans.unsqueeze(1)?.expand((s, s, hp))?; // head varies along dim0
67        let t_rep = spans.unsqueeze(0)?.expand((s, s, hp))?; // tail varies along dim1
68        let cat = Tensor::cat(&[h_rep, t_rep], 2)?.reshape((s * s, 2 * hp))?;
69        let lin = self.u.forward(&cat)?.reshape((s, s, self.n_rel))?;
70        bilinear + lin
71    }
72}
73
74/// Pool one span from encoder hidden states: `start ⊕ mean` over the tokens overlapping the span, giving
75/// a `2H` representation. Endpoint+mean is the standard span encoding; it keeps boundary information that
76/// a pure mean discards.
77pub fn pool_span(hidden: &Tensor, offsets: &[(usize, usize)], span: &LabeledSpan) -> candle_core::Result<Tensor> {
78    let idx: Vec<u32> = offsets
79        .iter()
80        .enumerate()
81        .filter(|(_, (ts, te))| te > ts && *ts < span.end && span.start < *te)
82        .map(|(i, _)| i as u32)
83        .collect();
84    let h = hidden.i(0)?; // [T,H]
85    if idx.is_empty() {
86        // no token overlaps (truncation) → zero vector of the right width
87        let dim = h.dim(1)?;
88        let z = Tensor::zeros(dim, h.dtype(), h.device())?;
89        return Tensor::cat(&[z.clone(), z], 0);
90    }
91    let sel = Tensor::from_vec(idx.clone(), idx.len(), h.device())?;
92    let toks = h.index_select(&sel, 0)?; // [n,H]
93    let start = toks.i(0)?;
94    let mean = toks.mean(0)?;
95    Tensor::cat(&[start, mean], 0)
96}
97
98/// Per-pair target class: index into `[none, rel_0, rel_1, …]`.
99fn pair_targets(spec: &VocabularySpace, ex: &TaggerExample) -> Vec<Vec<usize>> {
100    let names: Vec<&str> = spec.relation_facets.iter().map(|r| r.name.as_str()).collect();
101    let n = ex.spans.len();
102    let mut t = vec![vec![0usize; n]; n];
103    for r in &ex.relations {
104        if let Some(ri) = names.iter().position(|n| *n == r.name) {
105            if r.head < n && r.tail < n {
106                t[r.head][r.tail] = ri + 1;
107            }
108        }
109    }
110    t
111}
112
113/// The type mask: `true` where relation class `r` is declarable for a pair of facets. Class 0 (`none`) is
114/// always allowed; a declared relation is allowed only for its exact `head`/`tail` facets.
115pub fn type_allowed(spec: &VocabularySpace, mask: &HashSet<(String, String, String)>, fh: &str, ft: &str, class: usize) -> bool {
116    if class == 0 {
117        return true;
118    }
119    match spec.relation_facets.get(class - 1) {
120        Some(r) => mask.contains(&(fh.to_string(), ft.to_string(), r.name.clone())),
121        None => false,
122    }
123}
124
125#[derive(Debug, Clone, Serialize)]
126pub struct RelReport {
127    pub examples: usize,
128    pub pairs: usize,
129    pub classes: usize,
130    pub first_loss: f64,
131    pub last_loss: f64,
132    /// accuracy over scored pairs after training
133    pub train_acc: f64,
134    /// accuracy over only the pairs that carry a real relation (not `none`) — the metric that matters,
135    /// since `none` dominates
136    pub train_acc_positive: f64,
137    /// held-out examples (never trained on)
138    pub dev_examples: usize,
139    /// accuracy on held-out pairs — the only number that says anything about generalisation. Train
140    /// accuracy saturates trivially here: the type mask plus one-relation-per-sentence generation leaves
141    /// little ambiguity, so a perfect train score is memorisation, not skill.
142    pub dev_acc: f64,
143    pub dev_acc_positive: f64,
144}
145
146/// Train Head C on top of a (frozen) tuned encoder. The encoder is reloaded read-only from the step-2
147/// checkpoint: Head A already learned the span representations, so Head C only needs to learn the pair
148/// geometry, which keeps this stage cheap and stable.
149pub fn train_relations(
150    spec: &VocabularySpace,
151    examples: &[TaggerExample],
152    cfg: &TrainConfig,
153    tagger_dir: &std::path::Path,
154    epochs: usize,
155) -> Result<(VarMap, RelReport), String> {
156    let device = Device::Cpu;
157    let bert_cfg: candle_transformers::models::bert::Config = serde_json::from_slice(
158        &std::fs::read(cfg.base_dir.join("config.json")).map_err(|e| format!("base config: {e}"))?,
159    )
160    .map_err(|e| format!("parse base config: {e}"))?;
161    let tok = Tokenizer::from_file(&cfg.tokenizer).map_err(|e| format!("tokenizer: {e}"))?;
162    let n_a = crate::tagger_data::head_a_labels(spec).len();
163    let n_b = crate::tagger_data::head_b_labels().len();
164
165    // frozen encoder from step 2
166    let weights = tagger_dir.join("tagger.safetensors");
167    let vb_frozen = unsafe {
168        VarBuilder::from_mmaped_safetensors(&[weights.clone()], DType::F32, &device)
169            .map_err(|e| format!("load {}: {e}", weights.display()))?
170    };
171    let encoder = MultiHeadTagger::new(vb_frozen, &hrm_config_from(&bert_cfg), n_a, n_b).map_err(|e| format!("build encoder: {e}"))?;
172
173    // trainable biaffine head
174    let hp = 2 * bert_cfg.hidden_size;
175    let n_rel = spec.relation_facets.len() + 1;
176    let varmap = VarMap::new();
177    let vb = VarBuilder::from_varmap(&varmap, DType::F32, &device);
178    let head = BiaffineHead::new(vb, hp, n_rel).map_err(|e| format!("build head C: {e}"))?;
179    let mask = pair_mask(spec);
180
181    // pre-encode: (span reps, targets, facets) per example — the encoder is frozen so this is done once
182    struct Prepared {
183        spans: Tensor, // [S,H']
184        targets: Vec<Vec<usize>>,
185        facets: Vec<String>,
186    }
187    let mut prepared: Vec<Prepared> = Vec::new();
188    for ex in examples {
189        if ex.spans.len() < 2 {
190            continue;
191        }
192        let enc = match tok.encode(ex.text.as_str(), true) {
193            Ok(e) => e,
194            Err(_) => continue,
195        };
196        let n = enc.get_ids().len().min(cfg.max_len);
197        let ids = Tensor::from_vec(enc.get_ids()[..n].to_vec(), (1, n), &device).map_err(|e| e.to_string())?;
198        let attn = Tensor::from_vec(vec![1u32; n], (1, n), &device).map_err(|e| e.to_string())?;
199        let hidden = encoder.hidden(&ids, &attn, false).map_err(|e| format!("encode: {e}"))?;
200        let offsets: Vec<(usize, usize)> = enc.get_offsets()[..n].to_vec();
201        let reps: Vec<Tensor> = ex
202            .spans
203            .iter()
204            .map(|sp| pool_span(&hidden, &offsets, sp))
205            .collect::<candle_core::Result<Vec<_>>>()
206            .map_err(|e| format!("pool: {e}"))?;
207        let spans = Tensor::stack(&reps, 0).map_err(|e| e.to_string())?.detach();
208        prepared.push(Prepared { spans, targets: pair_targets(spec, ex), facets: ex.spans.iter().map(|s| s.facet.clone()).collect() });
209    }
210    if prepared.len() < 5 {
211        return Err("too few examples with >=2 spans to split train/dev".into());
212    }
213    // deterministic 80/20 holdout by stride so every relation/case is represented in both halves
214    let mut dev: Vec<Prepared> = Vec::new();
215    let mut train_set: Vec<Prepared> = Vec::new();
216    for (i, p) in prepared.into_iter().enumerate() {
217        if i % 5 == 4 {
218            dev.push(p);
219        } else {
220            train_set.push(p);
221        }
222    }
223    let prepared = train_set;
224
225    let mut opt = AdamW::new(varmap.all_vars(), ParamsAdamW { lr: cfg.lr, ..Default::default() })
226        .map_err(|e| format!("optimizer: {e}"))?;
227    let (mut first_loss, mut last_loss) = (f64::NAN, f64::NAN);
228    let mut total_pairs = 0usize;
229
230    for epoch in 1..=epochs {
231        let mut sum = 0.0f64;
232        let mut steps = 0usize;
233        for p in &prepared {
234            let logits = head.forward(&p.spans).map_err(|e| format!("head C forward: {e}"))?;
235            let s = p.facets.len();
236            // gather type-allowed pairs (excluding the diagonal: a span never relates to itself)
237            let mut rows: Vec<Tensor> = Vec::new();
238            let mut tgts: Vec<u32> = Vec::new();
239            for i in 0..s {
240                for j in 0..s {
241                    if i == j {
242                        continue;
243                    }
244                    let cls = p.targets[i][j];
245                    // mask: disallowed classes get -inf so they are unrepresentable
246                    let row = logits.i((i, j)).map_err(|e| e.to_string())?; // [R1]
247                    let allow: Vec<f32> = (0..n_rel)
248                        .map(|c| if type_allowed(spec, &mask, &p.facets[i], &p.facets[j], c) { 0.0 } else { f32::NEG_INFINITY })
249                        .collect();
250                    let allow = Tensor::from_vec(allow, n_rel, &device).map_err(|e| e.to_string())?;
251                    rows.push((row + allow).map_err(|e| e.to_string())?.unsqueeze(0).map_err(|e| e.to_string())?);
252                    tgts.push(cls as u32);
253                }
254            }
255            if rows.is_empty() {
256                continue;
257            }
258            let batch = Tensor::cat(&rows, 0).map_err(|e| e.to_string())?;
259            let tgt = Tensor::from_vec(tgts.clone(), tgts.len(), &device).map_err(|e| e.to_string())?;
260            let l = loss::cross_entropy(&batch, &tgt).map_err(|e| format!("ce: {e}"))?;
261            opt.backward_step(&l).map_err(|e| format!("backward: {e}"))?;
262            sum += l.to_scalar::<f32>().map_err(|e| e.to_string())? as f64;
263            steps += 1;
264            if epoch == 1 {
265                total_pairs += tgts.len();
266            }
267        }
268        let avg = sum / steps.max(1) as f64;
269        if epoch == 1 {
270            first_loss = avg;
271        }
272        last_loss = avg;
273    }
274
275    // accuracy over a set
276    let score = |set: &[Prepared]| -> Result<(f64, f64), String> {
277        let (mut ok, mut n, mut ok_pos, mut n_pos) = (0usize, 0usize, 0usize, 0usize);
278        for p in set {
279            let logits = head.forward(&p.spans).map_err(|e| e.to_string())?;
280            let s = p.facets.len();
281            for i in 0..s {
282                for j in 0..s {
283                    if i == j {
284                        continue;
285                    }
286                    let row = logits.i((i, j)).map_err(|e| e.to_string())?;
287                    let allow: Vec<f32> = (0..n_rel)
288                        .map(|c| if type_allowed(spec, &mask, &p.facets[i], &p.facets[j], c) { 0.0 } else { f32::NEG_INFINITY })
289                        .collect();
290                    let allow = Tensor::from_vec(allow, n_rel, &device).map_err(|e| e.to_string())?;
291                    let pred = softmax(&(row + allow).map_err(|e| e.to_string())?, D::Minus1)
292                        .and_then(|t| t.argmax(D::Minus1))
293                        .and_then(|t| t.to_scalar::<u32>())
294                        .map_err(|e| e.to_string())? as usize;
295                    let want = p.targets[i][j];
296                    n += 1;
297                    if pred == want {
298                        ok += 1;
299                    }
300                    if want != 0 {
301                        n_pos += 1;
302                        if pred == want {
303                            ok_pos += 1;
304                        }
305                    }
306                }
307            }
308        }
309        Ok((ok as f64 / n.max(1) as f64, ok_pos as f64 / n_pos.max(1) as f64))
310    };
311    let (dev_acc, dev_acc_pos) = score(&dev)?;
312
313    // accuracy
314    let (mut ok, mut n, mut ok_pos, mut n_pos) = (0usize, 0usize, 0usize, 0usize);
315    for p in &prepared {
316        let logits = head.forward(&p.spans).map_err(|e| e.to_string())?;
317        let s = p.facets.len();
318        for i in 0..s {
319            for j in 0..s {
320                if i == j {
321                    continue;
322                }
323                let row = logits.i((i, j)).map_err(|e| e.to_string())?;
324                let allow: Vec<f32> = (0..n_rel)
325                    .map(|c| if type_allowed(spec, &mask, &p.facets[i], &p.facets[j], c) { 0.0 } else { f32::NEG_INFINITY })
326                    .collect();
327                let allow = Tensor::from_vec(allow, n_rel, &device).map_err(|e| e.to_string())?;
328                let pred = softmax(&(row + allow).map_err(|e| e.to_string())?, D::Minus1)
329                    .and_then(|t| t.argmax(D::Minus1))
330                    .and_then(|t| t.to_scalar::<u32>())
331                    .map_err(|e| e.to_string())? as usize;
332                let want = p.targets[i][j];
333                n += 1;
334                if pred == want {
335                    ok += 1;
336                }
337                if want != 0 {
338                    n_pos += 1;
339                    if pred == want {
340                        ok_pos += 1;
341                    }
342                }
343            }
344        }
345    }
346
347    let report = RelReport {
348        examples: prepared.len(),
349        pairs: total_pairs,
350        classes: n_rel,
351        first_loss: round4(first_loss),
352        last_loss: round4(last_loss),
353        train_acc: round4(ok as f64 / n.max(1) as f64),
354        train_acc_positive: round4(ok_pos as f64 / n_pos.max(1) as f64),
355        dev_examples: dev.len(),
356        dev_acc: round4(dev_acc),
357        dev_acc_positive: round4(dev_acc_pos),
358    };
359    Ok((varmap, report))
360}
361
362fn round4(v: f64) -> f64 {
363    (v * 10000.0).round() / 10000.0
364}
365
366/// Persist Head C beside the tagger checkpoint.
367pub fn save(varmap: &VarMap, spec: &VocabularySpace, out_dir: &std::path::Path) -> Result<(), String> {
368    std::fs::create_dir_all(out_dir).map_err(|e| e.to_string())?;
369    varmap.save(out_dir.join("relations.safetensors")).map_err(|e| format!("save head C: {e}"))?;
370    let meta = serde_json::json!({
371        "classes": crate::tagger_data::head_c_labels(spec),
372        "relations": spec.relation_facets,
373    });
374    std::fs::write(out_dir.join("relations.json"), serde_json::to_vec_pretty(&meta).map_err(|e| e.to_string())?)
375        .map_err(|e| e.to_string())?;
376    Ok(())
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::tagger_data::{Case, RelationLabel};
383    use crate::vocabulary::{EntityFacet, RelationFacet};
384
385    fn spec() -> VocabularySpace {
386        VocabularySpace {
387            version: 1,
388            corpus: "t".into(),
389            entity_facets: vec![
390                EntityFacet { name: "org".into(), parent: None, description: "companies".into(), examples: vec![], structural: false },
391                EntityFacet { name: "system".into(), parent: None, description: "platforms".into(), examples: vec![], structural: false },
392            ],
393            relation_facets: vec![RelationFacet { name: "develops".into(), head: "org".into(), tail: "system".into() }],
394            gazetteer: vec![],
395            metrics: None,
396        }
397    }
398
399    #[test]
400    fn type_mask_makes_reversed_relations_unrepresentable() {
401        let s = spec();
402        let m = pair_mask(&s);
403        // `none` always allowed
404        assert!(type_allowed(&s, &m, "system", "org", 0));
405        // declared direction allowed
406        assert!(type_allowed(&s, &m, "org", "system", 1));
407        // reversed direction is NOT scorable — the guarded fragment inside the model
408        assert!(!type_allowed(&s, &m, "system", "org", 1));
409        // undeclared facet pairing is not scorable either
410        assert!(!type_allowed(&s, &m, "org", "org", 1));
411    }
412
413    #[test]
414    fn pair_targets_are_directional() {
415        let s = spec();
416        let ex = TaggerExample {
417            text: "Boeing develops the MQ-28.".into(),
418            spans: vec![
419                LabeledSpan { start: 0, end: 6, facet: "org".into(), surface: "Boeing".into(), negated: false, hedged: false },
420                LabeledSpan { start: 20, end: 25, facet: "system".into(), surface: "MQ-28".into(), negated: false, hedged: false },
421            ],
422            relations: vec![RelationLabel { head: 0, tail: 1, name: "develops".into() }],
423            case: Case::Normal,
424        };
425        let t = pair_targets(&s, &ex);
426        assert_eq!(t[0][1], 1, "org→system carries the relation");
427        assert_eq!(t[1][0], 0, "system→org is `none`");
428    }
429
430    #[test]
431    fn biaffine_shapes_and_pooling() {
432        let device = Device::Cpu;
433        let varmap = VarMap::new();
434        let vb = VarBuilder::from_varmap(&varmap, DType::F32, &device);
435        let (hp, n_rel, s) = (8usize, 3usize, 4usize);
436        let head = BiaffineHead::new(vb, hp, n_rel).unwrap();
437        let spans = Tensor::rand(0f32, 1f32, (s, hp), &device).unwrap();
438        let logits = head.forward(&spans).unwrap();
439        assert_eq!(logits.dims(), &[s, s, n_rel]);
440
441        // pooling gives 2H and respects span/token overlap
442        let hidden = Tensor::rand(0f32, 1f32, (1, 5, 4), &device).unwrap();
443        let offsets = [(0, 0), (0, 6), (7, 15), (16, 21), (0, 0)];
444        let sp = LabeledSpan { start: 0, end: 6, facet: "org".into(), surface: "Boeing".into(), negated: false, hedged: false };
445        let pooled = pool_span(&hidden, &offsets, &sp).unwrap();
446        assert_eq!(pooled.dims(), &[8]); // 2 * H
447        // a span outside every token still yields the right width (zeros)
448        let far = LabeledSpan { start: 900, end: 905, facet: "org".into(), surface: "x".into(), negated: false, hedged: false };
449        assert_eq!(pool_span(&hidden, &offsets, &far).unwrap().dims(), &[8]);
450    }
451}