1use anno::{Identity, IdentityId, TrackRef};
7use std::collections::{HashMap, HashSet};
8
9#[derive(Debug, Clone)]
11pub struct InterDocCorefMetrics {
12 pub cluster_purity: f64,
14 pub cluster_completeness: f64,
16 pub num_pred_identities: usize,
18 pub num_gold_identities: usize,
20 pub num_correct: usize,
22 pub num_total: usize,
24}
25
26impl InterDocCorefMetrics {
27 #[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 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 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 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 let mut total_purity = 0.0;
71 let mut total_completeness = 0.0;
72 let mut num_correct = 0;
73
74 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 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 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 num_correct += max_count;
100 }
101 }
102
103 for cluster in gold.iter() {
105 if cluster.is_empty() {
106 continue;
107 }
108
109 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 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 #[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 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 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 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 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 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}