Skip to content

Tracking change over time

A literature is a moving target. Run the same search a few months apart and the result will have grown, and it may also have lost a record that was re-indexed under a different identifier. This guide shows how to compare two harvests of the same plan and see exactly which records the literature gained or lost in between. Both harvests here are cut from the corpus bundled with the package, 138 real articles on graphene supercapacitors that stand in for a Scopus retrieval, since retrieved records may not be redistributed and no key is assumed. The guide closes with the live form, where the later harvest comes from fetch_plan instead.

Two harvests to compare

A harvest is the normalised frame that fetch_plan returns, with the stable RECORD_COLUMNS schema. The baseline below is that same search as it stood at the start of 2022, which is the bundled harvest cut off after 2021.

import scopusflow as sf

records = sf.example_records()
baseline = records[records["year"] <= 2021].reset_index(drop=True)
out(baseline[["entry_number", "doi", "title", "year"]].tail(3))
entry_number doi title year
92 10.47540/ijias.v1i3.303 Comparative Analytical Modeling and Performance Investigation of Graphene-Based Super Capacitor with Four Traditional Batteries 2021
93 10.1016/j.petrol.2021.109829 An innovative graphene- supercapacitor for the treatment of crude oil viscosity at low temperatures 2021
94 10.1039/d1ta09222g Enhancing the energy storage capacity of graphene supercapacitors via solar heating 2021

That leaves 94 of the 138 records. Every row carries the entry_number it held in the harvest, which is what makes a partial fetch resumable, and a year parsed from the cover date. A live pull arrives in the same shape, because fetch_plan passes the pybliometrics result through to_records, which strips the eid back to a bare scopus_id and parses the coverDate. That identifier column is empty in the bundled set, these records not having come from Scopus, so the comparison below rests on the DOI, which is what it would key on in a live setting anyway.

Some months on the search is repeated, and by then the three later years have been indexed. This second pull also loses one record from the earlier one, which is what happens when a paper is re-indexed under a different identifier, so we drop the first row to stand for it.

later = records.drop(index=records.index[0]).reset_index(drop=True)
out(later[["entry_number", "doi", "title", "year"]].tail(3))
entry_number doi title year
136 10.1016/j.surfin.2024.105581 Laser repeat scanning preparation of nitrogen sulfur doped graphene supercapacitor 2024
137 10.1016/j.isci.2024.111696 Boosting flexible laser-induced graphene supercapacitors performance through double pass laser processing 2024
138 10.1016/j.jpowsour.2024.236149 High-yield liquid phase production of high-quality graphene via dimethylacetamide-ethanol mixed solvent system 2024

Pulling the DOIs out

The comparison runs on DOIs, so it helps to see what a harvest reduces to first. extract_dois reads the doi column from a record frame and returns a cleaned list. It strips a resolver prefix such as https://doi.org/ and a leading doi: label, and by default it de-duplicates case-insensitively, since the same DOI can arrive in different letter cases.

dois = sf.extract_dois(baseline)
out((len(dois), dois[:3]))
(84, ['10.15541/jim20140527', '10.1021/am509065d', '10.1016/j.electacta.2015.02.019'])

The 94 baseline records reduce to 84 DOIs, the ten records that carry none being dropped, since a blank is no use to anything reading the list.

It also accepts a plain list, which is handy when the DOIs come from somewhere other than a harvest, for instance a column you read from a file. The cleaning and de-duplication apply either way.

# 10.5555 is the reserved DOI test prefix, so none of these resolve.
out(sf.extract_dois(["https://doi.org/10.5555/AB-1", "doi: 10.5555/ab-1",
                     "10.5555/cd-2"]))
['10.5555/AB-1', '10.5555/cd-2']

That list collapses to two entries because the first two are the same DOI in different cases. Pass dedupe=False to keep every entry as it came in, including repeats.

What changed

diff_dois compares two harvests and returns a DataFrame with a doi column and a status column, where the status is added, removed or unchanged. It calls extract_dois on each side for you, so you can hand it the record frames directly, and the comparison is case-insensitive throughout.

changes = sf.diff_dois(old=baseline, new=later)
out(changes.head())
doi status
10.1002/adfm.202315137 added
10.1002/asia.202400548 added
10.1002/ente.202201120 added
10.1002/slct.202302535 added
10.1002/smll.202301533 added

The 43 papers indexed since the baseline come back as added, the 83 present in both pulls as unchanged, and the one that fell out of the later pull as removed. The frame is sorted by status then DOI, so the categories group together.

To act on one category, filter the frame on status as you would any pandas DataFrame.

out(changes[changes["status"] == "removed"])
doi status
10.15541/jim20140527 removed

A count per category gives a quick read on how much moved between the two pulls.

out(changes["status"].value_counts())
status count
unchanged 83
added 43
removed 1

Merging without duplicates

To carry a cumulative set forward across pulls, scopus_combine binds the harvests, renumbers entry_number across the result and, with dedupe=True, keeps one copy of each record. Records retrieved from Scopus are keyed on scopus_id. These carry none, so the key falls back to the DOI, compared case-insensitively.

combined = sf.scopus_combine(baseline, later, dedupe=True)
out((len(combined), len(sf.extract_dois(combined))))
(148, 127)

That takes 231 concatenated rows down to 148: the 127 distinct DOIs, plus the 21 records carrying none, which cannot be matched this way and are kept, where collapsing them into one another would be a silent guess.

The merge records itself, which matters because the count exists only while it happens: afterwards nothing in the result says how many rows went in. PRISMA-S asks for exactly that figure, and scopus_search_report reads it back from here, so the item is answered.

out(combined.attrs["combined"])
{'n_in': 231, 'n_out': 148, 'n_removed': 83, 'deduplicated': True}

Keeping a record of each pull

Comparing against a past harvest only works if you kept it, so it is worth saving each pull as you go. A record frame is an ordinary pandas DataFrame, which means the usual pandas writers and readers round-trip it. Parquet preserves the column types exactly, which matters for the nullable integer columns in the schema.

import pandas as pd

baseline.to_parquet("baseline.parquet")
restored = pd.read_parquet("baseline.parquet")
sf.diff_dois(old=restored, new=later)

If you would rather have a plain-text artefact to commit alongside the analysis, baseline.to_csv("baseline.csv", index=False) works too, with pandas.read_csv to read it back.

In a live setting

Everything above runs offline because both harvests were cut from the bundled corpus. In a live setting the later harvest comes from the API, and that call needs a configured Scopus API key, which pybliometrics reads from its own configuration. The shape of the comparison does not change. You re-run the same SearchPlan through fetch_plan, read back the harvest you saved earlier, and diff the two.

import pandas as pd

q = sf.scopus_query("graphene", "supercapacitor", field="TITLE-ABS-KEY")
plan = sf.SearchPlan(q, years=range(2015, 2025), partition="year")

later = sf.fetch_plan(plan, cache_dir="graphene-harvest-2")
baseline = pd.read_parquet("baseline.parquet")
sf.diff_dois(old=baseline, new=later)

Run that on a schedule against a saved baseline and the added and removed rows tell you, harvest after harvest, precisely how the literature is shifting under your search.