Skip to main content

anno_eval/eval/
inter_doc_coref.rs

1//! Evaluation metrics for inter-document coreference resolution.
2//!
3//! Provides metrics specific to cross-document entity clustering,
4//! complementing the standard coreference metrics in `coref_metrics.rs`.
5
6use anno::{Identity, IdentityId, TrackRef};
7use std::collections::{HashMap, HashSet};
8
9/// Metrics for inter-document coreference resolution quality.
10#[derive(Debug, Clone)]
11pub struct InterDocCorefMetrics {
12    /// Cluster purity: average fraction of tracks in each identity that are correct
13    pub cluster_purity: f64,
14    /// Cluster completeness: average fraction of correct tracks that are in the same identity
15    pub cluster_completeness: f64,
16    /// Number of predicted identities
17    pub num_pred_identities: usize,
18    /// Number of gold identities
19    pub num_gold_identities: usize,
20    /// Number of tracks correctly clustered
21    pub num_correct: usize,
22    /// Total number of tracks
23    pub num_total: usize,
24}
25
26impl InterDocCorefMetrics {
27    /// Compute metrics comparing predicted identities to gold standard.
28    ///
29    /// # Arguments
30    ///
31    /// * `predicted` - Predicted identities from corpus
32    /// * `gold` - Gold standard identities (track_refs grouped by identity)
33    ///
34    /// # Returns
35    ///
36    /// Metrics with purity, completeness, and counts.
37    #[must_use]
38    pub fn compute(predicted: &[Identity], gold: &[Vec<TrackRef>]) -> Self {
39        if predicted.is_empty() && gold.is_empty() {
40            return Self::default();
41        }
42
43        // Build track_ref -> identity_id mapping for predicted
44        let mut pred_map: HashMap<TrackRef, IdentityId> = HashMap::new();
45        for identity in predicted {
46            if let Some(anno::IdentitySource::CrossDocCoref { track_refs }) = &identity.source {
47                for track_ref in track_refs {
48                    pred_map.insert(track_ref.clone(), identity.id);
49                }
50            }
51        }
52
53        // Build track_ref -> gold cluster index mapping
54        let mut gold_map: HashMap<TrackRef, usize> = HashMap::new();
55        for (idx, cluster) in gold.iter().enumerate() {
56            for track_ref in cluster {
57                gold_map.insert(track_ref.clone(), idx);
58            }
59        }
60
61        // Get all track refs
62        let all_tracks: HashSet<_> = pred_map.keys().chain(gold_map.keys()).cloned().collect();
63        let num_total = all_tracks.len();
64
65        if num_total == 0 {
66            return Self::default();
67        }
68
69        // Compute cluster purity and completeness
70        let mut total_purity = 0.0;
71        let mut total_completeness = 0.0;
72        let mut num_correct = 0;
73
74        // For each predicted identity, compute purity
75        for identity in predicted {
76            if let Some(anno::IdentitySource::CrossDocCoref { track_refs }) = &identity.source {
77                if track_refs.is_empty() {
78                    continue;
79                }
80
81                // Count how many tracks in this identity share the same gold cluster
82                let mut gold_cluster_counts: HashMap<usize, usize> = HashMap::new();
83                for track_ref in track_refs {
84                    if let Some(&gold_cluster) = gold_map.get(track_ref) {
85                        *gold_cluster_counts.entry(gold_cluster).or_insert(0) += 1;
86                    }
87                }
88
89                // Purity: fraction of tracks that share the most common gold cluster
90                let max_count = gold_cluster_counts.values().max().copied().unwrap_or(0);
91                let purity = if track_refs.is_empty() {
92                    0.0
93                } else {
94                    max_count as f64 / track_refs.len() as f64
95                };
96                total_purity += purity * track_refs.len() as f64;
97
98                // Count correct links
99                num_correct += max_count;
100            }
101        }
102
103        // For each gold cluster, compute completeness
104        for cluster in gold.iter() {
105            if cluster.is_empty() {
106                continue;
107            }
108
109            // Count how many tracks in this gold cluster share the same predicted identity
110            let mut pred_identity_counts: HashMap<IdentityId, usize> = HashMap::new();
111            for track_ref in cluster {
112                if let Some(&pred_identity) = pred_map.get(track_ref) {
113                    *pred_identity_counts.entry(pred_identity).or_insert(0) += 1;
114                }
115            }
116
117            // Completeness: fraction of tracks that share the most common predicted identity
118            let max_count = pred_identity_counts.values().max().copied().unwrap_or(0);
119            let completeness = if cluster.is_empty() {
120                0.0
121            } else {
122                max_count as f64 / cluster.len() as f64
123            };
124            total_completeness += completeness * cluster.len() as f64;
125        }
126
127        let cluster_purity = if num_total > 0 {
128            total_purity / num_total as f64
129        } else {
130            0.0
131        };
132
133        let cluster_completeness = if num_total > 0 {
134            total_completeness / num_total as f64
135        } else {
136            0.0
137        };
138
139        Self {
140            cluster_purity,
141            cluster_completeness,
142            num_pred_identities: predicted.len(),
143            num_gold_identities: gold.len(),
144            num_correct,
145            num_total,
146        }
147    }
148
149    /// Compute F1 score from purity and completeness.
150    #[must_use]
151    pub fn f1(&self) -> f64 {
152        if self.cluster_purity + self.cluster_completeness == 0.0 {
153            0.0
154        } else {
155            2.0 * self.cluster_purity * self.cluster_completeness
156                / (self.cluster_purity + self.cluster_completeness)
157        }
158    }
159}
160
161impl Default for InterDocCorefMetrics {
162    fn default() -> Self {
163        Self {
164            cluster_purity: 0.0,
165            cluster_completeness: 0.0,
166            num_pred_identities: 0,
167            num_gold_identities: 0,
168            num_correct: 0,
169            num_total: 0,
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use anno::{GroundedDocument, Location, Signal, Track, TrackId};
178
179    fn create_test_corpus() -> (anno::Corpus, Vec<Vec<TrackRef>>) {
180        let mut corpus = anno::Corpus::new();
181
182        // Document 1: "Apple" and "Microsoft"
183        let mut doc1 = GroundedDocument::new("doc1", "Apple and Microsoft");
184        let s1 = doc1.add_signal(Signal::new(0, Location::text(0, 5), "Apple", "Org", 0.9));
185        let s2 = doc1.add_signal(Signal::new(
186            1,
187            Location::text(10, 19),
188            "Microsoft",
189            "Org",
190            0.9,
191        ));
192        let mut track1 = Track::new(0, "Apple");
193        track1.add_signal(s1, 0);
194        let mut track2 = Track::new(1, "Microsoft");
195        track2.add_signal(s2, 0);
196        doc1.add_track(track1);
197        doc1.add_track(track2);
198        corpus.add_document(doc1);
199
200        // Document 2: "Apple Inc"
201        let mut doc2 = GroundedDocument::new("doc2", "Apple Inc");
202        let s3 = doc2.add_signal(Signal::new(
203            0,
204            Location::text(0, 10),
205            "Apple Inc",
206            "Org",
207            0.9,
208        ));
209        let mut track3 = Track::new(0, "Apple Inc");
210        track3.add_signal(s3, 0);
211        doc2.add_track(track3);
212        corpus.add_document(doc2);
213
214        // Document 3: "Microsoft Corp"
215        let mut doc3 = GroundedDocument::new("doc3", "Microsoft Corp");
216        let s4 = doc3.add_signal(Signal::new(
217            0,
218            Location::text(0, 13),
219            "Microsoft Corp",
220            "Org",
221            0.9,
222        ));
223        let mut track4 = Track::new(0, "Microsoft Corp");
224        track4.add_signal(s4, 0);
225        doc3.add_track(track4);
226        corpus.add_document(doc3);
227
228        // Resolve inter-doc coref
229        use anno::coalesce::Resolver;
230        let resolver = Resolver::new().with_threshold(0.3).require_type_match(true);
231        let _identity_ids = resolver.resolve_inter_doc_coref(&mut corpus, None, None);
232
233        // Gold standard: Apple tracks should cluster, Microsoft tracks should cluster
234        let gold = vec![
235            vec![
236                TrackRef {
237                    doc_id: "doc1".to_string(),
238                    track_id: TrackId::new(0),
239                },
240                TrackRef {
241                    doc_id: "doc2".to_string(),
242                    track_id: TrackId::new(0),
243                },
244            ],
245            vec![
246                TrackRef {
247                    doc_id: "doc1".to_string(),
248                    track_id: TrackId::new(1),
249                },
250                TrackRef {
251                    doc_id: "doc3".to_string(),
252                    track_id: TrackId::new(0),
253                },
254            ],
255        ];
256
257        (corpus, gold)
258    }
259
260    #[test]
261    fn test_inter_doc_coref_metrics_basic() {
262        let (corpus, gold) = create_test_corpus();
263
264        let identity_ids: Vec<_> = corpus
265            .identities()
266            .values()
267            .filter(|id| matches!(id.source, Some(anno::IdentitySource::CrossDocCoref { .. })))
268            .map(|id| id.id)
269            .collect();
270        let predicted: Vec<_> = identity_ids
271            .iter()
272            .filter_map(|&id| corpus.get_identity(id))
273            .cloned()
274            .collect();
275
276        let metrics = InterDocCorefMetrics::compute(&predicted, &gold);
277
278        assert!(metrics.cluster_purity >= 0.0 && metrics.cluster_purity <= 1.0);
279        assert!(metrics.cluster_completeness >= 0.0 && metrics.cluster_completeness <= 1.0);
280        assert!(metrics.f1() >= 0.0 && metrics.f1() <= 1.0);
281    }
282
283    #[test]
284    fn test_inter_doc_coref_metrics_empty() {
285        let metrics = InterDocCorefMetrics::compute(&[], &[]);
286        assert_eq!(metrics.cluster_purity, 0.0);
287        assert_eq!(metrics.cluster_completeness, 0.0);
288        assert_eq!(metrics.f1(), 0.0);
289    }
290}