citegraph 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
citegraph/dedup.py ADDED
@@ -0,0 +1,498 @@
1
+ """Fuzzy canonicalization of bibliographic works.
2
+
3
+ Cleaned-up port of the algorithm originally in ``label_papers_old.py``.
4
+ We compute a weighted similarity over title / authors / journal and then
5
+ perform a single-pass clustering: each row is compared against the
6
+ representatives of existing clusters; the first cluster that scores above
7
+ the threshold (and has a year within ``year_window``) wins. Otherwise the
8
+ row starts a new cluster.
9
+
10
+ Usage
11
+ -----
12
+
13
+ >>> from citegraph.dedup import canonicalize_works, DedupConfig
14
+ >>> works, edges, stats = canonicalize_works(sources, citations_raw, DedupConfig())
15
+
16
+ ``works`` is a canonical DataFrame indexed by stable ``w-`` work ids with
17
+ ``ring``/``source_file`` provenance columns; ``edges`` is the deduplicated
18
+ ``citing_id``/``cited_id`` table; ``stats`` reports merges, dropped
19
+ self-loops, and the per-citation-row cluster mapping.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import re
26
+ import unicodedata
27
+ from collections import defaultdict
28
+ from collections.abc import Callable
29
+ from dataclasses import dataclass
30
+
31
+ import pandas as pd
32
+ from rapidfuzz.fuzz import ratio, token_set_ratio
33
+
34
+ from citegraph._progress import iter_with_progress
35
+ from citegraph.ids import _first_author_token, make_work_id
36
+ from citegraph.io import parse_authors_list, require_columns
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ _NON_ALNUM = re.compile(r"[^\w\s]", re.UNICODE)
42
+ _WS = re.compile(r"\s+")
43
+
44
+
45
+ def normalize_text(text: object) -> str:
46
+ """Lowercase, strip, and remove non-alphanumeric characters.
47
+
48
+ Lists of authors are joined with commas before normalisation.
49
+ """
50
+ if isinstance(text, list):
51
+ text = ", ".join(str(t) for t in text)
52
+ if not isinstance(text, str):
53
+ return ""
54
+ text = unicodedata.normalize("NFKD", text)
55
+ text = "".join(ch for ch in text if not unicodedata.combining(ch))
56
+ text = _NON_ALNUM.sub(" ", text.casefold())
57
+ return _WS.sub(" ", text).strip()
58
+
59
+
60
+ @dataclass
61
+ class DedupConfig:
62
+ """Tunable thresholds for fuzzy deduplication."""
63
+
64
+ title_weight: float = 0.65
65
+ authors_weight: float = 0.25
66
+ journal_weight: float = 0.10
67
+ year_window: int = 1
68
+ threshold: float = 85.0
69
+
70
+
71
+ def _read_year(value: object) -> tuple[int | None, bool]:
72
+ """Coerce ``value`` to a year for the dedup year predicate.
73
+
74
+ Returns ``(year, parsed_ok)``:
75
+
76
+ - ``(None, True)`` — the value is *missing* (None / "" / sentinel ``0`` /
77
+ non-positive). Callers should not use the year window to reject the
78
+ cluster in this case; the title/authors fuzzy score still has to clear
79
+ the threshold, which is what carries the matching decision.
80
+ - ``(int, True)`` — a usable year.
81
+ - ``(None, False)`` — the value was non-empty but couldn't be parsed
82
+ (e.g. ``"abc"``). Callers should fail closed in this case, preserving
83
+ the original conservative behaviour for corrupt data.
84
+ """
85
+ if value is None or pd.isna(value) or value == "":
86
+ return None, True
87
+ try:
88
+ year = int(value)
89
+ except (TypeError, ValueError, OverflowError):
90
+ return None, False
91
+ if year <= 0:
92
+ return None, True
93
+ return year, True
94
+
95
+
96
+ def compare_papers(paper1: dict, paper2: dict, cfg: DedupConfig) -> bool:
97
+ """Return ``True`` if two records are likely the same paper.
98
+
99
+ Each record is a dict with at least ``Title``, ``Authors``, ``Journal``
100
+ and ``Year`` keys (the same shape returned by Gemini extraction).
101
+
102
+ The year predicate is *tolerant* of missing data (the schema's sentinel
103
+ ``Year=0``, ``None``, or empty string): if either side is missing, we
104
+ decline to reject on year and let title/authors carry the decision.
105
+ Genuinely unparseable values (e.g. a stray non-numeric string) still
106
+ fail closed — see :func:`_read_year`.
107
+ """
108
+ return _assess_work_match(paper1, paper2, cfg)["decision"] == "match"
109
+
110
+
111
+ def _normalize_work_record(record: dict) -> dict:
112
+ """Normalize representations without changing bibliographic display values."""
113
+ out = dict(record)
114
+ authors = parse_authors_list(record.get("Authors_List"))
115
+ if authors and not isinstance(record.get("Authors"), str):
116
+ out["Authors"] = ", ".join(authors)
117
+ elif authors and not record.get("Authors", "").strip():
118
+ out["Authors"] = ", ".join(authors)
119
+ return out
120
+
121
+
122
+ def _assess_work_match(paper1: dict, paper2: dict, cfg: DedupConfig) -> dict:
123
+ """Score a pair, retaining reasons when strong fuzzy evidence is unsafe."""
124
+ paper1, paper2 = _normalize_work_record(paper1), _normalize_work_record(paper2)
125
+ title1 = normalize_text(paper1.get("Title"))
126
+ title2 = normalize_text(paper2.get("Title"))
127
+ raw_title1 = paper1.get("Title") if isinstance(paper1.get("Title"), str) else ""
128
+ raw_title2 = paper2.get("Title") if isinstance(paper2.get("Title"), str) else ""
129
+ subtitle_shortening = False
130
+ for raw_left, raw_right in ((raw_title1, raw_title2), (raw_title2, raw_title1)):
131
+ if ":" in raw_left or "—" in raw_left:
132
+ prefix = raw_left.replace("—", ":").split(":", 1)[0]
133
+ subtitle_shortening |= _title_without_leading_article(normalize_text(prefix)) == _title_without_leading_article(normalize_text(raw_right))
134
+ title_score = token_set_ratio(
135
+ _title_without_leading_article(title1), _title_without_leading_article(title2)
136
+ )
137
+ authors_score = token_set_ratio(
138
+ normalize_text(paper1.get("Authors")), normalize_text(paper2.get("Authors"))
139
+ )
140
+ journal_score = token_set_ratio(
141
+ normalize_text(paper1.get("Journal")), normalize_text(paper2.get("Journal"))
142
+ )
143
+
144
+ weighted = (
145
+ title_score * cfg.title_weight
146
+ + authors_score * cfg.authors_weight
147
+ + journal_score * cfg.journal_weight
148
+ )
149
+
150
+ y1, ok1 = _read_year(paper1.get("Year"))
151
+ y2, ok2 = _read_year(paper2.get("Year"))
152
+ if not ok1 or not ok2:
153
+ year_ok = False # fail closed on unparseable data
154
+ elif y1 is None or y2 is None:
155
+ year_ok = True # at least one side missing -> rely on title/authors
156
+ else:
157
+ year_ok = abs(y1 - y2) <= cfg.year_window
158
+
159
+ reasons = _title_conflict_reasons(title1, title2, subtitle_shortening=subtitle_shortening)
160
+ if not year_ok:
161
+ decision, reasons = "reject", ["invalid_year" if not ok1 or not ok2 else "year_window"]
162
+ elif weighted < cfg.threshold:
163
+ decision, reasons = "reject", ["below_threshold"]
164
+ elif reasons:
165
+ decision = "review"
166
+ else:
167
+ decision, reasons = "match", ["compatible_metadata"]
168
+ return dict(decision=decision, reason_codes=reasons, title_score=title_score,
169
+ title_full_score=ratio(title1, title2), authors_score=authors_score,
170
+ journal_score=journal_score, weighted_score=weighted, year_ok=year_ok)
171
+
172
+
173
+ _LEADING_ARTICLES = {"a", "an", "the"}
174
+ _NEGATION_WORDS = {"no", "not", "without", "non"}
175
+ _PART_MARKER = re.compile(r"^(part|volume|vol|book)$")
176
+
177
+
178
+ def _title_without_leading_article(title: str) -> str:
179
+ words = title.split()
180
+ return " ".join(words[1:] if words and words[0] in _LEADING_ARTICLES else words)
181
+
182
+
183
+ def _title_conflict_reasons(left: str, right: str, *, subtitle_shortening: bool = False) -> list[str]:
184
+ """Guard high-scoring containment matches that change work identity."""
185
+ lw = _title_without_leading_article(left).split()
186
+ rw = _title_without_leading_article(right).split()
187
+ reasons = []
188
+ if (set(lw) & _NEGATION_WORDS) != (set(rw) & _NEGATION_WORDS):
189
+ reasons.append("negation_conflict")
190
+ def marker(words: list[str]) -> tuple[str, str] | None:
191
+ for i, word in enumerate(words[:-1]):
192
+ if _PART_MARKER.match(word) and re.fullmatch(r"[ivxlcdm]+|\d+", words[i + 1]):
193
+ return word, words[i + 1]
194
+ return None
195
+ lm, rm = marker(lw), marker(rw)
196
+ if lm != rm and (lm or rm):
197
+ reasons.append("part_conflict")
198
+ # Explicit subtitle omission may explain containment, but cannot explain
199
+ # away the protected conflicts above. No semantic equivalence is inferred.
200
+ if not subtitle_shortening and (set(lw) < set(rw) or set(rw) < set(lw)):
201
+ reasons.append("title_expansion")
202
+ return reasons
203
+
204
+
205
+ def _row_to_dict(row: pd.Series) -> dict:
206
+ # Authors_List is preserved alongside the comma-joined Authors so that
207
+ # downstream stages (author normalization in particular) can recover
208
+ # individual author strings without having to split the joined form —
209
+ # comma-splitting "Smith, J., García, A." would mistake each initial
210
+ # for its own author.
211
+ authors_list = parse_authors_list(row.get("Authors_List"))
212
+ authors = row.get("Authors")
213
+ if (not isinstance(authors, str) or not authors.strip()) and isinstance(authors_list, list):
214
+ authors = "; ".join(str(a) for a in authors_list)
215
+ return {
216
+ "Authors": authors,
217
+ "Authors_List": row.get("Authors_List"),
218
+ "Journal": row.get("Journal"),
219
+ "Title": row.get("Title"),
220
+ "Year": row.get("Year"),
221
+ }
222
+
223
+
224
+ def _title_block_key(title: object) -> str:
225
+ words = _title_without_leading_article(normalize_text(title)).split()
226
+ return " ".join(words[:6])
227
+
228
+
229
+ def _author_block_key(row: pd.Series) -> str:
230
+ authors_list = parse_authors_list(row.get("Authors_List"))
231
+ value = authors_list or row.get("Authors", "")
232
+ def strip_accents(text: str) -> str:
233
+ return "".join(c for c in unicodedata.normalize("NFKD", text) if not unicodedata.combining(c))
234
+ if isinstance(value, list):
235
+ value = [strip_accents(a) for a in value]
236
+ elif isinstance(value, str):
237
+ value = strip_accents(value)
238
+ return _first_author_token(value)
239
+
240
+
241
+ def _title_block_keys(title: object) -> set[str]:
242
+ full = _title_without_leading_article(normalize_text(title))
243
+ if not full:
244
+ return set()
245
+ words = full.split()
246
+ return {"full:" + full, "prefix:" + " ".join(words[:6]),
247
+ "prefix4:" + " ".join(words[:4]),
248
+ "tokens:" + " ".join(sorted(set(full.split())))}
249
+
250
+
251
+ def _years_can_match(a: object, b: object, cfg: DedupConfig) -> bool:
252
+ y1, ok1 = _read_year(a)
253
+ y2, ok2 = _read_year(b)
254
+ if not ok1 or not ok2:
255
+ return False
256
+ if y1 is None or y2 is None:
257
+ return True
258
+ return abs(y1 - y2) <= cfg.year_window
259
+
260
+
261
+ def _candidate_index_lookup(df: pd.DataFrame) -> tuple[dict[str, set[int]], dict[str, set[int]], set[int]]:
262
+ author_blocks: dict[str, set[int]] = defaultdict(set)
263
+ title_blocks: dict[str, set[int]] = defaultdict(set)
264
+ unknown_author: set[int] = set()
265
+
266
+ for idx, row in df.iterrows():
267
+ author_key = _author_block_key(row)
268
+ author_blocks[author_key].add(idx)
269
+ if author_key == "unknown":
270
+ unknown_author.add(idx)
271
+ for title_key in _title_block_keys(row.get("Title")):
272
+ title_blocks[title_key].add(idx)
273
+ return author_blocks, title_blocks, unknown_author
274
+
275
+
276
+ def _candidate_indices(
277
+ row: pd.Series,
278
+ *,
279
+ author_blocks: dict[str, set[int]],
280
+ title_blocks: dict[str, set[int]],
281
+ unknown_author: set[int],
282
+ ) -> set[int]:
283
+ author_key = _author_block_key(row)
284
+ if author_key == "unknown":
285
+ # Symmetric with the known-author side's inclusion of unknown rows.
286
+ return set().union(*author_blocks.values()) if author_blocks else set()
287
+ candidates = set(author_blocks.get(author_key, set())) | unknown_author
288
+ for title_key in _title_block_keys(row.get("Title")):
289
+ candidates.update(title_blocks.get(title_key, set()))
290
+ return candidates
291
+
292
+
293
+ def _cluster_rows(
294
+ df: pd.DataFrame,
295
+ cfg: DedupConfig,
296
+ make_cluster_id: Callable[[int, pd.Series], str],
297
+ *,
298
+ show_progress: bool,
299
+ description: str,
300
+ decisions: list[dict] | None = None,
301
+ collisions: list[dict] | None = None,
302
+ ) -> tuple[list[str], list[tuple[str, dict]]]:
303
+ """Single-pass blocked clustering shared by dedup and canonicalization.
304
+
305
+ Returns the per-row cluster id list and ``(cluster_id, representative
306
+ dict)`` pairs in first-seen order. The representative is always the
307
+ *first* member of its cluster.
308
+ """
309
+ cluster_ids: list[str | None] = [None] * len(df)
310
+ representatives: list[tuple[str, dict]] = []
311
+ used_ids: set[str] = set()
312
+ author_blocks, title_blocks, unknown_author = _candidate_index_lookup(df)
313
+
314
+ for i in iter_with_progress(
315
+ list(range(len(df))),
316
+ show_progress=show_progress,
317
+ description=description,
318
+ item_label=lambda idx: f"row {idx + 1}/{len(df)}",
319
+ ):
320
+ if cluster_ids[i] is not None:
321
+ continue
322
+ paper_i = _row_to_dict(df.iloc[i])
323
+ cluster_id = make_cluster_id(i, df.iloc[i])
324
+ # Distinct works can slug to the same id (e.g. two untitled citations
325
+ # by the same first author and year); downstream tables join by id,
326
+ # so collisions get a deterministic -2/-3… suffix like author ids do.
327
+ if cluster_id in used_ids:
328
+ n = 2
329
+ while f"{cluster_id}-{n}" in used_ids:
330
+ n += 1
331
+ new_id = f"{cluster_id}-{n}"
332
+ if collisions is not None:
333
+ collisions.append({"row_index": i, "base_id": cluster_id, "assigned_id": new_id})
334
+ cluster_id = new_id
335
+ used_ids.add(cluster_id)
336
+ cluster_ids[i] = cluster_id
337
+ representatives.append((cluster_id, paper_i))
338
+
339
+ candidate_js = _candidate_indices(
340
+ df.iloc[i],
341
+ author_blocks=author_blocks,
342
+ title_blocks=title_blocks,
343
+ unknown_author=unknown_author,
344
+ )
345
+ for j in sorted(idx for idx in candidate_js if idx > i):
346
+ if cluster_ids[j] is not None:
347
+ continue
348
+ if not _years_can_match(df.iloc[i].get("Year"), df.iloc[j].get("Year"), cfg):
349
+ continue
350
+ assessment = _assess_work_match(paper_i, _row_to_dict(df.iloc[j]), cfg)
351
+ if decisions is not None and assessment["decision"] != "reject":
352
+ decisions.append({"left_index": i, "right_index": j,
353
+ "representative_id": cluster_id, **assessment})
354
+ if assessment["decision"] == "match":
355
+ cluster_ids[j] = cluster_id
356
+ # Every entry is filled: each row either joined a cluster or started one.
357
+ return cluster_ids, representatives # type: ignore[return-value]
358
+
359
+
360
+ def _compute_rings(ring0_ids: set[str], edges: pd.DataFrame) -> dict[str, int]:
361
+ """BFS discovery depth from the ring-0 seed set over citation edges."""
362
+ rings = {wid: 0 for wid in ring0_ids}
363
+ out_edges: dict[str, set[str]] = defaultdict(set)
364
+ for citing, cited in zip(edges["citing_id"], edges["cited_id"], strict=False):
365
+ out_edges[str(citing)].add(str(cited))
366
+ frontier = set(ring0_ids)
367
+ depth = 0
368
+ while frontier:
369
+ depth += 1
370
+ nxt = {c for w in frontier for c in out_edges.get(w, ()) if c not in rings}
371
+ for c in nxt:
372
+ rings[c] = depth
373
+ frontier = nxt
374
+ return rings
375
+
376
+
377
+ _WORK_COLUMNS = ["ring", "source_file", "Title", "Authors", "Authors_List", "Journal", "Year"]
378
+
379
+
380
+ def canonicalize_works(
381
+ sources: pd.DataFrame,
382
+ citations_raw: pd.DataFrame,
383
+ cfg: DedupConfig | None = None,
384
+ *,
385
+ show_progress: bool = True,
386
+ ) -> tuple[pd.DataFrame, pd.DataFrame, dict]:
387
+ """Cluster sources + raw citations into canonical works.
388
+
389
+ Sources occupy the head of the combined frame, so the single-pass
390
+ first-match-wins loop guarantees: (a) two matching sources merge
391
+ (duplicate PDFs), (b) a citation matching a source becomes that
392
+ work, and (c) every cluster representative — hence its metadata —
393
+ is the full-text side whenever one exists.
394
+
395
+ Returns ``(works, edges, stats)``: ``works`` is indexed by ``id``
396
+ with ``ring``/``source_file`` ahead of the bibliographic columns;
397
+ ``edges`` is the deduplicated ``citing_id``/``cited_id`` table with
398
+ self-loops removed; ``stats`` counts what was dropped or merged.
399
+ """
400
+ cfg = cfg or DedupConfig()
401
+ require_columns(sources, ["id", "source_file", "Title", "Year"], artifact="sources")
402
+ require_columns(citations_raw, ["Title", "Year", "citing_id"], artifact="citations_raw")
403
+ for frame, name in ((sources, "sources"), (citations_raw, "citations_raw")):
404
+ if "Authors" not in frame.columns and "Authors_List" not in frame.columns:
405
+ raise ValueError(f"{name} is missing required column: Authors or Authors_List")
406
+
407
+ src = sources.reset_index(drop=True)
408
+ cit = citations_raw.reset_index(drop=True)
409
+ n_src = len(src)
410
+
411
+ if not n_src and cit.empty:
412
+ empty_works = pd.DataFrame(columns=_WORK_COLUMNS)
413
+ empty_works.index.name = "id"
414
+ empty_edges = pd.DataFrame(columns=["citing_id", "cited_id"])
415
+ return empty_works, empty_edges, {
416
+ "n_self_loops_dropped": 0,
417
+ "n_source_duplicates_merged": 0,
418
+ "source_cluster_ids": [],
419
+ "citation_cluster_ids": [],
420
+ "decisions": [],
421
+ "id_collisions": [],
422
+ }
423
+
424
+ combined = pd.concat([src, cit], ignore_index=True, sort=False)
425
+
426
+ def _cluster_id(i: int, row: pd.Series) -> str:
427
+ if i < n_src:
428
+ return str(row["id"])
429
+ return make_work_id(
430
+ row.get("Authors_List") or row.get("Authors", ""),
431
+ row.get("Year"),
432
+ row.get("Title", ""),
433
+ )
434
+
435
+ decisions: list[dict] = []
436
+ collisions: list[dict] = []
437
+ cluster_ids, representatives = _cluster_rows(
438
+ combined,
439
+ cfg,
440
+ _cluster_id,
441
+ show_progress=show_progress,
442
+ description="Canonicalizing works",
443
+ decisions=decisions,
444
+ collisions=collisions,
445
+ )
446
+
447
+ source_canonical = {str(src.iloc[i]["id"]): cluster_ids[i] for i in range(n_src)}
448
+ ring0_ids = set(cluster_ids[:n_src])
449
+
450
+ edges = pd.DataFrame(
451
+ {
452
+ "citing_id": [
453
+ source_canonical.get(str(c), str(c)) for c in cit["citing_id"]
454
+ ],
455
+ "cited_id": cluster_ids[n_src:],
456
+ }
457
+ )
458
+ n_before = len(edges)
459
+ edges = edges[edges["citing_id"] != edges["cited_id"]]
460
+ n_self_loops = n_before - len(edges)
461
+ edges = edges.drop_duplicates().reset_index(drop=True)
462
+
463
+ source_file_by_cluster: dict[str, str] = {}
464
+ for i in range(n_src):
465
+ source_file_by_cluster.setdefault(cluster_ids[i], str(src.iloc[i]["source_file"]))
466
+
467
+ rings = _compute_rings(ring0_ids, edges)
468
+ records = []
469
+ for cluster_id, rep in representatives:
470
+ record = dict(rep)
471
+ record["id"] = cluster_id
472
+ # A work absent from `rings` can only be an orphan whose sole
473
+ # inbound edge was a dropped self-loop variant; ring 1 is the
474
+ # defensively correct depth for it.
475
+ record["ring"] = rings.get(cluster_id, 1)
476
+ record["source_file"] = source_file_by_cluster.get(cluster_id, "")
477
+ records.append(record)
478
+ works = pd.DataFrame(records).set_index("id")[_WORK_COLUMNS]
479
+
480
+ stats = {
481
+ "n_self_loops_dropped": int(n_self_loops),
482
+ "n_source_duplicates_merged": int(n_src - len(ring0_ids)),
483
+ # Per-citation-row canonical id, aligned with citations_raw order.
484
+ # Lets callers (e.g. the report's merge audit) reconstruct which
485
+ # raw citation events merged into which work.
486
+ "citation_cluster_ids": [str(c) for c in cluster_ids[n_src:]],
487
+ "source_cluster_ids": [str(c) for c in cluster_ids[:n_src]],
488
+ "decisions": decisions,
489
+ "id_collisions": collisions,
490
+ }
491
+ logger.info(
492
+ "Canonicalized %d sources + %d citations into %d works (%d edges)",
493
+ n_src,
494
+ len(cit),
495
+ len(works),
496
+ len(edges),
497
+ )
498
+ return works, edges, stats