Skip to content

Commit dbeefbf

Browse files
jodeleeuwclaude
andcommitted
Classify papers by OpenAlex field; surface per-journal on dashboard
papers.csv gains topic_field/topic_subfield from OpenAlex primary_topic (blank for EPMC-only records until their journal covers them). journals.csv gains a field column: the modal field across each journal's classified works, which indirectly classifies EPMC-only papers via journal overlap. The dashboard journals table adds a sortable Field column and a field filter dropdown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2142c00 commit dbeefbf

7 files changed

Lines changed: 820 additions & 616 deletions

File tree

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ One row per unique paper.
2626
| `publication_year` | Year of publication. |
2727
| `publication_date` | Publication date (`YYYY-MM-DD`) when known. |
2828
| `type` | Work type (e.g. `article`, `preprint`). |
29+
| `topic_field` | **Derived, recomputed every run** — the display name of the OpenAlex `primary_topic` field (e.g. `Psychology`). OpenAlex assigns topics **algorithmically**, so occasional misassignments are expected. Blank for all Europe PMC-only records and for OpenAlex records without a primary topic; newly added columns stay blank until the next full monthly fetch backfills them (`--relink` does not refetch and leaves these columns untouched). |
30+
| `topic_subfield` | **Derived** — the OpenAlex `primary_topic` subfield display name (e.g. `Cognitive Neuroscience`). Same provenance and caveats as `topic_field`. |
2931
| `is_preprint` | **Derived, recomputed every run** — manual edits will not stick (unlike `notes`/`exclude`). `True` only when a strong preprint signal fires: `type` is exactly `preprint`, the DOI prefix belongs to a known preprint server (PsyArXiv, OSF Preprints, bioRxiv/medRxiv, arXiv, Research Square, Preprints.org, SocArXiv, EdArXiv, SSRN), or the venue names one of those servers. `False` means "not confidently identified as a preprint", **not** "confirmed published". |
3032
| `duplicate_of` | **Derived, recomputed every run** — manual edits will not stick. When this row is a preprint (or earlier version) of another row in the dataset, the `id` of that canonical row; blank for canonical / unlinked rows. Chains are flattened (v1 → v2 → published becomes v1 → published), so a canonical row always has a blank `duplicate_of`. Rows are never deleted by linking — this column only annotates. See [Preprint → published linking](#preprint--published-linking). |
3133
| `match_method` | **Derived** — which signal produced the link: `crossref`, `doi_version`, or `title_author` (see below). Blank whenever `duplicate_of` is blank. |
@@ -170,8 +172,17 @@ only) derives two summary tables from the data CSVs:
170172
(distinct works), `n_authors` (distinct author keys), `first_use`,
171173
`last_use`.
172174
- **`analysis/journals.csv`** — one row per unique venue: `journal_key`,
173-
`journal_name`, `n_papers` (distinct works), `n_authors` (distinct author
174-
keys), `first_use`, `last_use`. Venues are keyed by normalized name
175+
`journal_name`, `field`, `n_papers` (distinct works), `n_authors`
176+
(distinct author keys), `first_use`, `last_use`. The `field` column is
177+
the **modal `topic_field` across the journal's classified works** (a
178+
work's own field is the modal value across its linked members; ties break
179+
by count then alphabetically; blank when no member work is classified).
180+
Because classification is per-journal, most Europe PMC-only papers are
181+
covered indirectly: their journal is classified by its OpenAlex-covered
182+
works. Expect the large majority of works to sit in a classified journal
183+
once the monthly fetch has backfilled `topic_field`, with a residual
184+
blank tail (unclassified OpenAlex records and journals seen only via
185+
Europe PMC). The script prints the exact coverage each run. Venues are keyed by normalized name
175186
(lowercased, runs of punctuation/whitespace collapsed to single spaces —
176187
this merges MEDLINE-style variants like *Journal of experimental
177188
psychology. General*) since the sources provide no ISSNs; `journal_name`

analysis/dashboard.json

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -71,151 +71,176 @@
7171
"name": "PsyArXiv",
7272
"n_papers": 266,
7373
"first_use": "2017-03-03",
74-
"last_use": "2026-07-02"
74+
"last_use": "2026-07-02",
75+
"field": ""
7576
},
7677
{
7778
"name": "Behavior Research Methods",
7879
"n_papers": 93,
7980
"first_use": "2014-03-28",
80-
"last_use": "2026-07-06"
81+
"last_use": "2026-07-06",
82+
"field": ""
8183
},
8284
{
8385
"name": "Cognition",
8486
"n_papers": 64,
8587
"first_use": "2017-02-15",
86-
"last_use": "2026-05-25"
88+
"last_use": "2026-05-25",
89+
"field": ""
8790
},
8891
{
8992
"name": "OSF Preprints",
9093
"n_papers": 64,
9194
"first_use": "2016-06-01",
92-
"last_use": "2026-07-02"
95+
"last_use": "2026-07-02",
96+
"field": ""
9397
},
9498
{
9599
"name": "bioRxiv (Cold Spring Harbor Laboratory)",
96100
"n_papers": 59,
97101
"first_use": "2017-09-15",
98-
"last_use": "2026-06-22"
102+
"last_use": "2026-06-22",
103+
"field": ""
99104
},
100105
{
101106
"name": "Scientific Reports",
102107
"n_papers": 56,
103108
"first_use": "2016-09-13",
104-
"last_use": "2026-06-12"
109+
"last_use": "2026-06-12",
110+
"field": ""
105111
},
106112
{
107113
"name": "Frontiers in Psychology",
108114
"n_papers": 45,
109115
"first_use": "2014-08-06",
110-
"last_use": "2026-06-04"
116+
"last_use": "2026-06-04",
117+
"field": ""
111118
},
112119
{
113120
"name": "Attention Perception & Psychophysics",
114121
"n_papers": 44,
115122
"first_use": "2016-02-04",
116-
"last_use": "2026-07-13"
123+
"last_use": "2026-07-13",
124+
"field": ""
117125
},
118126
{
119127
"name": "arXiv (Cornell University)",
120128
"n_papers": 43,
121129
"first_use": "2019-05-17",
122-
"last_use": "2026-06-24"
130+
"last_use": "2026-06-24",
131+
"field": ""
123132
},
124133
{
125134
"name": "Memory & Cognition",
126135
"n_papers": 39,
127136
"first_use": "2018-04-30",
128-
"last_use": "2026-05-29"
137+
"last_use": "2026-05-29",
138+
"field": ""
129139
},
130140
{
131141
"name": "PLoS ONE",
132142
"n_papers": 38,
133143
"first_use": "2015-06-26",
134-
"last_use": "2026-05-15"
144+
"last_use": "2026-05-15",
145+
"field": ""
135146
},
136147
{
137148
"name": "Psychonomic Bulletin & Review",
138149
"n_papers": 37,
139150
"first_use": "2016-07-06",
140-
"last_use": "2026-07-13"
151+
"last_use": "2026-07-13",
152+
"field": ""
141153
},
142154
{
143155
"name": "Quarterly Journal of Experimental Psychology",
144156
"n_papers": 31,
145157
"first_use": "2020-01-14",
146-
"last_use": "2026-05-03"
158+
"last_use": "2026-05-03",
159+
"field": ""
147160
},
148161
{
149162
"name": "Cognitive Science",
150163
"n_papers": 27,
151164
"first_use": "2015-01-01",
152-
"last_use": "2026-06-01"
165+
"last_use": "2026-06-01",
166+
"field": ""
153167
},
154168
{
155169
"name": "Journal of Cognition",
156170
"n_papers": 24,
157171
"first_use": "2019-01-01",
158-
"last_use": "2026-01-09"
172+
"last_use": "2026-01-09",
173+
"field": ""
159174
},
160175
{
161176
"name": "Open Mind",
162177
"n_papers": 23,
163178
"first_use": "2020-01-01",
164-
"last_use": "2026-06-30"
179+
"last_use": "2026-06-30",
180+
"field": ""
165181
},
166182
{
167183
"name": "Communications Psychology",
168184
"n_papers": 22,
169185
"first_use": "2022-10-17",
170-
"last_use": "2026-04-29"
186+
"last_use": "2026-04-29",
187+
"field": ""
171188
},
172189
{
173190
"name": "eLife",
174191
"n_papers": 21,
175192
"first_use": "2017-11-21",
176-
"last_use": "2026-03-27"
193+
"last_use": "2026-03-27",
194+
"field": ""
177195
},
178196
{
179197
"name": "Journal of Memory and Language",
180198
"n_papers": 20,
181199
"first_use": "2018-06-11",
182-
"last_use": "2026-06-20"
200+
"last_use": "2026-06-20",
201+
"field": ""
183202
},
184203
{
185204
"name": "Psychological Research",
186205
"n_papers": 20,
187206
"first_use": "2020-11-12",
188-
"last_use": "2026-02-07"
207+
"last_use": "2026-02-07",
208+
"field": ""
189209
},
190210
{
191211
"name": "SSRN Electronic Journal",
192212
"n_papers": 18,
193213
"first_use": "2022-01-01",
194-
"last_use": "2026-01-01"
214+
"last_use": "2026-01-01",
215+
"field": ""
195216
},
196217
{
197218
"name": "Consciousness and Cognition",
198219
"n_papers": 17,
199220
"first_use": "2019-09-12",
200-
"last_use": "2026-03-12"
221+
"last_use": "2026-03-12",
222+
"field": ""
201223
},
202224
{
203225
"name": "eScholarship (California Digital Library)",
204226
"n_papers": 17,
205227
"first_use": "2013-01-01",
206-
"last_use": "2024-01-01"
228+
"last_use": "2024-01-01",
229+
"field": ""
207230
},
208231
{
209232
"name": "Journal of Experimental Psychology General",
210233
"n_papers": 17,
211234
"first_use": "2018-11-28",
212-
"last_use": "2026-01-26"
235+
"last_use": "2026-01-26",
236+
"field": ""
213237
},
214238
{
215239
"name": "Nature Communications",
216240
"n_papers": 17,
217241
"first_use": "2019-03-08",
218-
"last_use": "2026-05-15"
242+
"last_use": "2026-05-15",
243+
"field": ""
219244
}
220245
],
221246
"top_institutions": [

analysis/generate_summaries.py

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"n_papers", "n_authors", "first_use", "last_use",
6565
]
6666
JOURNAL_OUT_COLUMNS = [
67-
"journal_key", "journal_name", "n_papers", "n_authors",
67+
"journal_key", "journal_name", "field", "n_papers", "n_authors",
6868
"first_use", "last_use",
6969
]
7070

@@ -101,6 +101,14 @@
101101
}
102102

103103

104+
def modal_value(counter):
105+
"""Most frequent value in a Counter; ties broken by count then
106+
alphabetically (deterministic). '' for an empty counter."""
107+
if not counter:
108+
return ""
109+
return min(counter.items(), key=lambda kv: (-kv[1], kv[0]))[0]
110+
111+
104112
def norm_name(name):
105113
"""Normalize a name for matching: lowercase, strip periods, collapse ws."""
106114
name = name.lower().replace(".", " ")
@@ -169,6 +177,7 @@ def canonical(pid):
169177
excluded = set()
170178
type_filtered = 0
171179
group_dates = {} # group id -> list of member date strings (non-excluded)
180+
group_field_votes = {} # group id -> Counter of non-blank topic_field
172181
for row in raw_papers:
173182
pid = row["id"]
174183
if (row.get("exclude") or "").strip() == "True":
@@ -178,7 +187,18 @@ def canonical(pid):
178187
excluded.add(pid)
179188
type_filtered += 1
180189
continue
181-
group_dates.setdefault(group_of[pid], []).append(paper_date(row))
190+
gid = group_of[pid]
191+
group_dates.setdefault(gid, []).append(paper_date(row))
192+
tfield = (row.get("topic_field") or "").strip()
193+
if tfield:
194+
group_field_votes.setdefault(gid, Counter())[tfield] += 1
195+
196+
# A work's field: modal topic_field across its non-excluded members
197+
# (member versions of the same work almost always agree; using the modal
198+
# value lets a classified preprint cover an unclassified published row
199+
# and vice versa). '' when no member is classified.
200+
work_field = {gid: modal_value(group_field_votes[gid])
201+
for gid in group_field_votes}
182202

183203
# first/last use per active group (min/max across its non-excluded members)
184204
group_span = {}
@@ -239,9 +259,13 @@ def canonical(pid):
239259
nvenue = norm_venue(venue)
240260
j_key = f"name:{nvenue}"
241261
jentry = journals.setdefault(
242-
j_key, {"names": Counter(), "papers": set(), "authors": set()})
262+
j_key, {"names": Counter(), "papers": set(), "authors": set(),
263+
"fields": Counter()})
243264
jentry["names"][venue] += 1 # one spelling vote per work
244265
jentry["papers"].add(gid)
266+
wf = work_field.get(gid, "")
267+
if wf:
268+
jentry["fields"][wf] += 1 # one field vote per classified work
245269
group_journal[gid] = j_key
246270

247271
# ------------------------------------------------------------------ #
@@ -470,6 +494,9 @@ def date_range(group_ids):
470494
journal_rows.append({
471495
"journal_key": key,
472496
"journal_name": most_frequent(entry["names"]),
497+
# Modal topic_field across the journal's classified works (ties:
498+
# count then alphabetical); '' when no member work is classified.
499+
"field": modal_value(entry["fields"]),
473500
"n_papers": len(entry["papers"]),
474501
"n_authors": len(entry["authors"]),
475502
"first_use": first,
@@ -499,13 +526,19 @@ def date_range(group_ids):
499526
works_by_year = [{"year": int(y), "n": year_counts[y]}
500527
for y in sorted(year_counts)]
501528

502-
def top_entries(rows, name_field, limit=25):
503-
return [{
504-
"name": r[name_field],
505-
"n_papers": r["n_papers"],
506-
"first_use": r["first_use"],
507-
"last_use": r["last_use"],
508-
} for r in rows[:limit]]
529+
def top_entries(rows, name_field, limit=25, extra_keys=()):
530+
out = []
531+
for r in rows[:limit]:
532+
e = {
533+
"name": r[name_field],
534+
"n_papers": r["n_papers"],
535+
"first_use": r["first_use"],
536+
"last_use": r["last_use"],
537+
}
538+
for k in extra_keys:
539+
e[k] = r[k]
540+
out.append(e)
541+
return out
509542

510543
dashboard = {
511544
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
@@ -518,7 +551,8 @@ def top_entries(rows, name_field, limit=25):
518551
"preprints_linked": preprints_linked,
519552
},
520553
"works_by_year": works_by_year,
521-
"top_journals": top_entries(journal_rows, "journal_name"),
554+
"top_journals": top_entries(journal_rows, "journal_name",
555+
extra_keys=("field",)),
522556
"top_institutions": top_entries(inst_rows, "institution_name"),
523557
"top_authors": top_entries(author_rows, "author_name"),
524558
}
@@ -549,6 +583,24 @@ def top_entries(rows, name_field, limit=25):
549583
print(f" Unique journals: {len(journal_rows)} "
550584
f"(venues recovered from DOI prefix: {venue_from_doi_prefix}, "
551585
f"works skipped for empty venue: {skipped_no_venue})")
586+
587+
# Field-classification coverage
588+
n_works = len(group_dates)
589+
journal_field = {r["journal_key"]: r["field"] for r in journal_rows}
590+
direct = sum(1 for gid in group_dates if work_field.get(gid, ""))
591+
covered = sum(
592+
1 for gid in group_dates
593+
if work_field.get(gid, "")
594+
or journal_field.get(group_journal.get(gid, ""), ""))
595+
classified_journals = sum(1 for r in journal_rows if r["field"])
596+
def pct(a, b):
597+
return f"{100.0 * a / b:.1f}%" if b else "n/a"
598+
print(f" Field coverage: {pct(direct, n_works)} of works classified "
599+
f"directly ({direct}/{n_works}); "
600+
f"{pct(classified_journals, len(journal_rows))} of journals "
601+
f"classified ({classified_journals}/{len(journal_rows)}); "
602+
f"{pct(covered, n_works)} of works in a classified journal or "
603+
f"classified directly ({covered}/{n_works})")
552604
print(" Top 5 authors by n_papers:")
553605
for r in author_rows[:5]:
554606
print(f" {r['n_papers']:>4} {r['author_name']} ({r['author_key']})")

0 commit comments

Comments
 (0)