diff options
-rw-r--r-- | extra/sql_dumps/README.md | 4 | ||||
-rw-r--r-- | proposals/2020_fuzzy_matching.md | 430 | ||||
-rw-r--r-- | python/fatcat_tools/harvest/doi_registrars.py | 26 | ||||
-rw-r--r-- | python/fatcat_web/entity_helpers.py | 6 | ||||
-rw-r--r-- | python/fatcat_web/templates/entity_base.html | 3 | ||||
-rw-r--r-- | python/tests/harvest_crossref.py | 2 |
6 files changed, 466 insertions, 5 deletions
diff --git a/extra/sql_dumps/README.md b/extra/sql_dumps/README.md index bb41e702..00639fb9 100644 --- a/extra/sql_dumps/README.md +++ b/extra/sql_dumps/README.md @@ -31,13 +31,15 @@ Or, in production: # production, as 'fatcat' user, in /srv/fatcat/src/rust: cat /tmp/fatcat_ident_releases.tsv | ./target/release/fatcat-export release --expand files,filesets,webcaptures,container -j8 | pigz > /srv/fatcat/snapshots/release_export_expanded.json.gz - cat /tmp/fatcat_ident_releases.tsv | ./target/release/fatcat-export release -j8 | pigz > /srv/fatcat/snapshots/release_export.json.gz cat /tmp/fatcat_ident_creators.tsv | ./target/release/fatcat-export creator -j8 | pigz > /srv/fatcat/snapshots/creator_export.json.gz cat /tmp/fatcat_ident_containers.tsv | ./target/release/fatcat-export container -j8 | pigz > /srv/fatcat/snapshots/container_export.json.gz cat /tmp/fatcat_ident_files.tsv | ./target/release/fatcat-export file -j8 | pigz > /srv/fatcat/snapshots/file_export.json.gz cat /tmp/fatcat_ident_filesets.tsv | ./target/release/fatcat-export fileset -j8 | pigz > /srv/fatcat/snapshots/fileset_export.json.gz cat /tmp/fatcat_ident_webcaptures.tsv | ./target/release/fatcat-export webcapture -j8 | pigz > /srv/fatcat/snapshots/webcapture_export.json.gz + # redundant with "release_export_expanded" + cat /tmp/fatcat_ident_releases.tsv | ./target/release/fatcat-export release -j8 | pigz > /srv/fatcat/snapshots/release_export.json.gz + Then usually move all these files to `/srv/fatcat/snapshots/`. ## HOWTO: Dump abstracts, release identifiers, file hashes, etc diff --git a/proposals/2020_fuzzy_matching.md b/proposals/2020_fuzzy_matching.md new file mode 100644 index 00000000..30c321e3 --- /dev/null +++ b/proposals/2020_fuzzy_matching.md @@ -0,0 +1,430 @@ + +Status: planned + +Bibliographic Entity Fuzzy Match and Verification +==================================================== + +This document summarizes work so far on entity "fuzzy matching" and match +verification, notes some specific upcoming use cases for such methods, and +proposes new generic routines to be implemented. + +There are three main routines desired: + +**Match verification:** given two bibliographic metadata records (or two entire +entities), confirm whether they refer to the exact same version of the entity. +Optionally, also determine if they are not the exact same entity, but are +variations of the same entity (eg, releases that should be grouped under a +shared work entity). Performance is not as critical as correctness. Should be a +pure, in-memory routine (not touch the network or external services). + +**Fuzzy matching:** of bibliographic metadata to entity in current/live Fatcat +catalog. Eg, given complete or partial metadata about a paper or other entity, +return a list of candidate matches. Emphasis on recall over precision; better +to return false matches than missed records. This would likely hit the fatcat +elasticsearch indexes (search.fatcat.wiki), which are continuously updated to +be in sync with the catalog itself (API). Expected to scale to, eg, 5-10 +lookups per second and operate over up to a couple million entities during +cleanup or merging operations. Should operate per record, on-demand, always +up-to-date (aka, not a batch process). + +**Bulk fuzzy match:** a tool capable of matching hundreds or billions of raw +metadata records against the entire fatcat catalog, resulting in candidate +fuzzy match pairs or groupings. The two main applications are de-duplicating or +grouping releases over the entire catalog (aka, matching the catalog against +itself), matching billions of citations (structured partial metadata) against +release entities, and grouping citations which fail to match to existing +entities against each other. Likely to include a batch mode, though may also +include an efficient per-record lookup API as well. + +As a terminology note, the outputs of a fuzzy match are called "candidate +matches", and the output of match verificatino would be "confirmed match" or +"confident match". A "self-match" is when an exact complete entity is compared +to a set it already exists in, and matches to itself (aka, fatcat entity +identifier is exact match). "Identifier matches" are an easy case when external +identifiers (like DOI, ISSN, ORCID) are used for matching; note that the Fatcat +lookup API always returns the first such match, when there may actually be +multiple entities with the same external identifier, so this may not always be +sufficient. + + +## Container Matching + +Containers are a simpler case, so will discuss those first. Function signatures +would look like: + + verify_container_match(left:container, right:container) -> status:str + match_container_fuzzy(record:??) -> [candidate:container] + +Verify statuses could be: + +- `exact`: all metadata fields match byte-for-byte +- `strong`: good confidence these are the same +- `weak`: could be a match, perhaps missing enough metadata to be confident +- `ambiguous`: not enough confidence that is or even isn't a match +- `not-match`: confident that these are *not* the same container (TODO: better word?) + +Alternatively, could return a confidence floating point number, but that puts +burdern of interpretation on external code. + +Fields of interest would be at least: + +- name +- original name +- aliases +- publisher +- abbrev + +Some test cases that match verification should probably handle (just making +these up): + +- differences in title whitespace and capitalization +- title of one record has ISSN appended ("PLOS One" matches "PLOS One + 1932-6203") +- title of one record has publisher appended ("PLOS One" matches "PLOS One + (Public Library of Science)") +- record with original (non-English) name in name field matches fatcat record + where the non-Enlish title is in `original_title` field +- difference of "The" doesn't matter ("Lancet" matches "The Lancet") +- detect and reject bogus names + +Nice to haves: + +- records with only abbreviation or acroynm match (for "official" abbreviations + and acronyms, which may need to be recorded as alias). +- records of full name match when the "official" title is now an acroynum (eg, + "British Medical Journal" matches "The BMJ") +- optionally, detect and return `ambiguous` for a fixed list of known-ambiguous + names (eg, two journals with very similar titles, name or acronym along can't + distinguish) + +Can't remember if we already have aliases stored in container entity `extra` +yet, but idea is to store "also known as" names to help with fuzzy matching. + +The main application of these routines would be adding container linkage to +releases where we have a container name but no identifier (eg, an ISSN). We +have millions of releases (many with DOIs) that have a container name but no +linked container. We also want to import millions of papers based on GROBID +metadata alone, where we will likely only extract a journal name, and will want +to do lookups on that. + +For the particular case of containers, we'll probably want to memoize results +(aka, cache lookups) for efficiency. Probably will also want a unified helper +function like: + + match_container(name:str, **kwargs) -> (status:str, match:Option<container>) + +which would handle caching (maybe this is a class/object not a function?), call +`match_container_fuzzy()` to get candidates, call `verify_container_match()` on +each candidate, and ensure there is only one `exact` or `strong` match. Eg, if +there are multiple `weak` matches, should return `ambiguous` for the group +overall. + +Another speculative application of these routines could be as part of chocula, +to try and find "true" ISSNs for records where we know the ISSN is not +registered or invalid (eg, ISSN checksum digit fails). Another would be to look +for duplicate container/ISSNs, where there are multiple ISSNs for the same +journal but only one has any papers actually published; in that case we would +maybe want to mark the other container entity as a "stub". + +Test datasets: + +- in kibana/ES, filter for "`container_id` does not exist" and + "`container_name` exists" +- in sandcrawler, dump `grobid` table and look in the metadata snippet, which + includes extracted journal info + + +## Release Matching (verification and fuzzy, not bulk fuzzy) + +For release entities, the signature probably looks something like: + + verify_release_match(left:release, right:release) -> status:str + how confident, based on metadata, that these are same release, or + should be grouped under same work? + exact_release_match(left:release, right:release) -> bool + exact_work_match(left:release, right:release) -> bool + +Instead of full release entities, we could use partial metadata in the form of +a python dataclass or named tuple. If we use a struct, will need a routine to +convert full entities to the partial struct; if we use full entities, will +probably want a helper to construct entities from partial metadata. + +Don't know which fields would be needed, but guessing at least the following, +in priority order: + +- if full entity: `ident` (and `work_id` for case of releases) +- `title` +- `authors` (at least list of surnames, possibly also raw and given names) +- `ext_ids` +- `release_year` (and full `release_date` if available) +- volume, issue, pages +- `subtitle` +- `original_title` +- container name (and `container_id` if available) +- `release_type` and `release_stage` (particularly for work/release grouping + vs. match) + +For releases, the "status" output could be: + +- `strong` (good confidence) +- `weak` (seems good, but maybe not enough metadata to be sure) +- `work-strong` (good confidence that same work, but not same release) +- `work-weak` +- `ambiguous` +- `not-match` (name?) + +Cases we should handle: + +- ability to filter out self-matches from fuzzy matcher. Eg, if we want to find + a duplicate of an existing release, should be able to filter out self-match + (as a kwarg?) +- ability to filter out entire categories of matches. eg, match only to + published works (`release_stage`), only to papers (`release_type`), only not + match to longtail OA papers (`is_longtail_oa` ES flag) +- have an in-memory stoplist for ambiguous titles (eg, "abstract", "index") +- differences in punctuation, capitalization +- single-character typos +- subtitle/title matching (eg: {title: "Important Research", subtitle: "A + History"} should match {title: "Important Research: A History", subtitle: + None}) +- record with only one author, surname only should be able to match against + full author list. maybe a flag to indicate this case? eg, for matching some + citation styles where only one author is listed +- years are +/- 1. certainly for pre-prints ("work matches"), but also + different databases sometimes have different semantics about whether + "publication date" or "submission date" or "indexed date" are used as the + `release_date` (which we consider the "publication date"). Maybe "weak" match + in this case +- (probably a lot more i'm not thinking of) + +Nice to have (but harder?): + +- journal/container matching where one side has an abbreviation, acronym, or + alias of the other. does this require a network fetch? maybe cached or + pre-loaded? may not be important, do testing first. Note that entire + container entity is transcluded when doing full release entity API lookups. + This may be important for citation matching/verification + +Will probably want a helper function to check that a metadata record (or +entity) has enough metadata and seems in-scope for matching. For example, a +record with title of just "Abstract" should probably be blocked from any match +attempt, because there are so many records with that metadata. On the other +hand, if there is an external identifier (eg, DOI), could still attempt a +direct match. Maybe something like: + + can_verify_match(release:release) -> boolean + +Test datasets: + +- PDFs crawled as part of longtail OA crawls (see context below). We have both + fatcat release already imported, and new GROBID-extracted works. Can use + glutton fuzzy matching for comparison, or verify those glutton matches +- many, many unmatched reference strings. will do separate + +Optionally, we could implement a glutton-compatible API with equivalent or +better performance for doing GROBID "header consolidation". If performance is +great, could even use it for reference conslidation at crawl/ingest time. + + +## Bulk Fuzzy + +Current concept for this is to implement Semantic Scholar's algorithm +(described below) in any of python, golang, or rust, to function for all these +use-cases: + +- grouping releases in fatcat which are variants of the same work (or, eg, + publisher registered multiple DOIs for same paper by accident) into the same + work +- reference matching from structured partial metadata to fatcat releases +- grouping of unmatched references (from reference matching) as structured + partial metadata, with the goal of finding, eg, the "100,000 most cited + papers which do not have a fatcat entity", and creating fatcat entities for + them + +Optionally, we could also architect/design this tool to replace biblio-glutton +for ingest-time "reference consolidation", by exposing a biblio-glutton +compatible API. If this isn't possible or hard it could become a later tool +instead. Eg, shouldn't sacrafice batch performance for this. In particular, for +ingest-time reference matching we'd want the backing corpus to be updated +continuously, which might be tricky or in conflict with batch-mode design. + +## Existing Fatcat Work + +### Hadoop matching pipeline + +In Summer 2018, the fatcat project had a wonderful volunteer, Ellen Spertus. +Ellen implemented a batch fuzzy matching pipeline in Scala (using the Scalding +map/reduce framework) that ran on our Hadoop cluster. We used it to "join" +GROBID metadata from PDF files against a Crossref metadata dump. + +The Scala job worked by converting input metadata records into simple "bibjson" +metadata subsets (plus keeping the original record identifiers, eg Crossref DOI +or PDF file hash, for later import). It created a key for each record by +normalizing the title (removing all whitespace and non-alphanumeric characters, +lower-casing, etc; we called this a "slug"), and filtering out keys from a +blocklist. We used the map/reduce framework to then join the two tables on +these keys, and then filtered the output pairs with a small bit of addiitonal +title similarity comparison logic, then dumped the list of pairs as a table to +Hadoop, sorted by join key (slug). I can't remember if we had any other +heuristics in the Scala code. + +This was followed by a second processing stage in python, which iterated over +the full list. It grouped pairs by key, and discarded any groups that had too +many pairs (on the assumption that the titles were too generic). It then ran +additional quality checks (much easier/faster to implement in python) on year, +author names (eg, checking that the number of authors matched). The output of +this filtering was then fed into a fatcat importer which matched the PDFs to +releases based on DOI. + +We got several million matches using this technique. In the end we really only +ran this pipeline once. Hadoop (and HBase in particular) ended up being +frustrating to work with, as jobs took hours or days to run, and many bugs +would only appear when run against the full dataset. A particular problem that +came up with the join approach was N^2 explosions for generic titles, where the +number of join rows would get very large (millions of rows) for generic titles, +even after we filtered out the top couple hundred most popular join keys. + +TODO: should update this section with specific algorithms and parameters used by +reading the Scala and Python source + +## Longtail OA Import Filtering + +Not direcly related to matching, but filtering mixed-quality metadata. + +As part of Longtail OA preservation work, we ran a crawl of small OA journal +websites, and then ran GROBID over the resulting PDFs to extract metadata. We +then filtered the output metadata using quality heuristics, then inserted both +new releases (with no external identifiers, just the extracted metadata) and +the associated file. + +The metadata filtering pipeline is interesting because of all the bad metadata +it detected. Eg, long titles, used the normalization and blocklist from the +Hadoop matching work, poor author metadata, etc. + +A big problem that was only noticed after this import was that actually many of +the papers imported were duplicates of existing fatcat papers with DOIs or +other identifiers. This was because the crawl ended up spidering some general +purpose repositories, which contained copies of large-publisher OA papers (eg, +PLOS papers). The solution to this will use fuzzy matching in two ways. First, +for future imports of this type, we will fuzzy matches against the catalog to +check that there isn't already a metadata record; possibly link the file to +matched existing entities (based on confidence), but certainly don't create new +records unless sure that there isn't an existing one (`no-match`). Also need to +ensure there are not duplicates *within* each import batch, but either running +the import slowly in a single thread (so elasticsearch and the matching system +has time to synchronize), or doing a batch fuzzy match first. Or possibly some +other pre-filtering idea. Secondly, we can go back over these longtail OA works +(which are tagged as such in the fatcat catalog) and attempt to match them +against non-longtail fatcat releases (eg, those with existing PMID or DOI), and +merge the releases together (note: redirecting the longtail release to the full +one, resulting in a single release, not just doing work grouping of releases). + +A separate problem from this import is that none of the papers have container +linkage (though many or all have container names successfully extracted). After +doing release-level merging, we should use container fuzzy matching to update +container linkage for these entities. We could potentially do this as a two +stage project where we dump all the container name strings, prioritize them by +release count, and iterate on container matching until we are happy with the +results, then run the actual release updates. + +### biblio-glutton + +[biblio-glutton][biblio-glutton] is a companion tool for GROBID to do record +matching and metadata enrichment of both "header" metadata (aka, extracted +metadata about the fulltext paper in the PDF itself) and references (extracted +from the reference/bibliography section of the fulltext paper). GROBID calls +this "consolidation". + +biblio-glutton supports a couple different metadata index sources, including +Crossref dumps. IA has patched both GROBID and biblio-glutton to work with +fatcat metadata directly, and to embed fatcat release identifiers in output +TEI-XML when there is a match. We have found performance to be fine for header +consolidation, but too slow for reference consolidation. This is because there +is only one glutton lookup per PDF for header mode, vs 20-50 lookups per PDF for +reference consolidation, which takes longer than the overall PDF extraction. In +our default configuration, we only do header consolidation. + +biblio-glutton runs as a REST API server, which can be queried separately from +the GROBID integration. It uses the JVM (can't remember if Java or Scala), and +works by doing an elasticsearch query to find candidates, then selects the best +candidate (if any) and looks up full metadata records from one or more LMDB +key/value databases. It requires it's own elasticsearch index with a custom +schema, and large LMDB files on disk (should be SSD for speed). The process of +updating the LMDB files and elasticsearch index are currently manual (with some +scripts), generated using bulk metadata fatcat dumps. This means the glutton +results get out of sync from the fatcat catalog. + +The current update process involves stopping glutton (which means stopping all +GROBID processing) for a couple hours. Compare this to the fatcat search index +(search.fatcat.wiki), which is continuously updated from the changelog feed, +and even during index schema changes has zero (or near zero) downtime. + +[biblio-glutton]: https://github.com/kermitt2/biblio-glutton + +## Existing External Work and Reading + +"The Lens MetaRecord and LensID: An open identifier system for aggregated +metadata and versioning of knowledge artefacts" +<https://osf.io/preprints/lissa/t56yh/> + + +### Semantic Scholar + +Semantic Scholar described their technique for doing bulk reference matching +and entity merging, summarized here: + +Fast candidate lookups are done using only a subset of the title. Title strings +are turned in to an array of normalized tokens (words), with stopwords (like +"a", "the") removed. Two separate indices are created: one with key as the the +first three tokens, and the other with the key as the last three tokens. For +each key, there will be a bucket of many papers (or just paper global +identifiers). A per-paper lookup by title will fetch candidates from both +indices. It is also possible to iterate over both indices by bucket and doing +further processing between all the papers, then combined the matches/groups +from both iterations. The reason for using two indices is to be robust against +mangled metadata where there is added junk or missing words at either the +begining or end of the title. + +To verify candidate pairs, the Jaccard similarity is calculated between the +full original title strings. This flexibly allows for character typos (human or +OCR), punctuation differences, and for longer titles missing or added words. +Roughly, the amount of difference is proportional to the total length of the +strings, so short titles must be a near-exact match, while long titles can have +entire whole words different. + +In addition to the title similarity check, only the first author surnames and +year of publication are further checked as heuristics to confirm matches. If I +recall correctly, not even the journal name is is compared. They have done both +performance and correctness evaluation of this process and are happy with the +results, particularly for reference matching. + +My (Bryan) commentary on this is that it is probably a good thing to try +implementing for bulk fuzzy candidate generation. I have noticed metadata +issues on Semantic Scholar with similarly titles papers getting grouped, or +fulltext PDF copies getting matched to the wrong paper. I think for fatcat we +should be more conservative, which we can do in our match verification +function. + +### Crossref + +Crossref has done a fair amount of work on the reference matching problem and +detecting duplicate records. In particular, Dominika Tkaczyk has published a +number of papers and blog posts: + + https://fatcat.wiki/release/search?q=author%3A%22Dominika+Tkaczyk%22 + https://www.crossref.org/authors/dominika-tkaczyk/ + +Some specific posts: + +"Double trouble with DOIs" +<https://www.crossref.org/blog/double-trouble-with-dois/> + +"Reference matching: for real this time" +<https://www.crossref.org/blog/reference-matching-for-real-this-time/> + +Java implementation and testing/evaluation framework for their reference +matcher: + +- <https://gitlab.com/crossref/search_based_reference_matcher> +- <https://gitlab.com/crossref/reference_matching_evaluation_framework> + diff --git a/python/fatcat_tools/harvest/doi_registrars.py b/python/fatcat_tools/harvest/doi_registrars.py index d2d71d3c..3acb7d96 100644 --- a/python/fatcat_tools/harvest/doi_registrars.py +++ b/python/fatcat_tools/harvest/doi_registrars.py @@ -16,12 +16,32 @@ from .harvest_common import HarvestState, requests_retry_session class HarvestCrossrefWorker: """ - Notes on crossref API: + Crossref API date fields (and our interpretation):: - - from-index-date is the updated time + - https://github.com/CrossRef/rest-api-doc#filter-names + - *-index-date: "metadata indexed" is the API/index record update time + - *-deposit-date: "metadata last (re)deposited" is the catalog record update time + - *-update-date: "Metadata updated (Currently the same as *-deposit-date)" + - *-created-date: "metadata first deposited" + - *-pub-date (etc): publisher-supplied, not "meta-meta-data" https://api.crossref.org/works?filter=from-index-date:2018-11-14&rows=2 + Also from the REST API: + + Notes on incremental metadata updates + + When using time filters to retrieve periodic, incremental metadata + updates, the from-index-date filter should be used over + from-update-date, from-deposit-date, from-created-date and + from-pub-date. The timestamp that from-index-date filters on is + guaranteed to be updated every time there is a change to metadata + requiring a reindex. + + However, when Crossref re-indexes tens of millions of rows, using + from-index-date can be very slow, taking several days to process a single + day of updates. + I think the design is going to have to be a cronjob or long-running job (with long sleeps) which publishes "success through" to a separate state queue, as simple YYYY-MM-DD strings. @@ -87,7 +107,7 @@ class HarvestCrossrefWorker: return Producer(producer_conf) def params(self, date_str): - filter_param = 'from-index-date:{},until-index-date:{}'.format( + filter_param = 'from-update-date:{},until-update-date:{}'.format( date_str, date_str) return { 'filter': filter_param, diff --git a/python/fatcat_web/entity_helpers.py b/python/fatcat_web/entity_helpers.py index 4d13da43..d82ea0e9 100644 --- a/python/fatcat_web/entity_helpers.py +++ b/python/fatcat_web/entity_helpers.py @@ -72,6 +72,12 @@ def enrich_release_entity(entity): # November 1. if ref.extra and ref.extra.get('unstructured'): ref.extra['unstructured'] = strip_extlink_xml(ref.extra['unstructured']) + # for backwards compatability, copy extra['subtitle'] to subtitle + if not entity.subtitle and entity.extra and entity.extra.get('subtitle'): + if isinstance(entity.extra['subtitle'], str): + entity.subtitle = entity.extra['subtitle'] + elif isinstance(entity.extra['subtitle'], list): + entity.subtitle = entity.extra['subtitle'][0] or None # author list to display; ensure it's sorted by index (any othors with # index=None go to end of list) authors = [c for c in entity.contribs if diff --git a/python/fatcat_web/templates/entity_base.html b/python/fatcat_web/templates/entity_base.html index 437bc071..f30df0da 100644 --- a/python/fatcat_web/templates/entity_base.html +++ b/python/fatcat_web/templates/entity_base.html @@ -26,6 +26,9 @@ <h1 class="ui header"> {% if entity_type == "container" %} {{ entity.name }} + {% if entity.extra.original_name %} + <br><span style="font-size: smaller; font-weight: normal;">{{ entity.extra.original_name }}</span> + {% endif %} {% elif entity_type == "creator" %} {{ entity.display_name }} {% elif entity_type == "file" %} diff --git a/python/tests/harvest_crossref.py b/python/tests/harvest_crossref.py index 52aa7b81..e902cda5 100644 --- a/python/tests/harvest_crossref.py +++ b/python/tests/harvest_crossref.py @@ -36,7 +36,7 @@ def test_crossref_harvest_date(mocker): assert "mailto:test@fatcat.wiki" in responses.calls[0].request.headers['User-Agent'] # check that correct date param was passed as expected - assert "filter=from-index-date%3A2019-02-03" in responses.calls[0].request.url + assert "filter=from-update-date%3A2019-02-03" in responses.calls[0].request.url # check that we published the expected number of DOI objects were published # to the (mock) kafka topic |