theoryminer
theoryminer turns a set of research papers into a causal map.
You give it PDF files. It finds the sentences that claim a cause and an effect, and it extracts each cause → effect pair. Then it brings together the different names that authors use for the same construct. The result is an interactive map. Each arrow leads back to the sentences that claim it, with the page and the DOI of each paper.
The pipeline
PDF ──harvest()──► sentences ──causenet()──► relations (cause span → effect span)
│
┌───────────────────────────────────┼───────────────────────────┐
▼ ▼ ▼
standardize_constructs() pairwise_grouping() / clusterer() (spans)
span → top-k thesaurus concepts spans → groups of similar spans
│
┌───────────────┴───────────────┐
▼ ▼
standardize_groups() label_groups()
group → thesaurus concept group → label from its own words
└───────────────┬───────────────┘
▼
causal_map() ──► draw_map() the interactive page
└─► save_map() CSV tables and GraphML
Two more stages feed the pipeline with papers. extract_references() finds the DOIs that your
papers cite. download_papers() downloads the open-access PDFs of those DOIs. Together they run a
snowball search: the papers of one round give the references of the next round.
What each step gives you
| Step | Function | Result |
|---|---|---|
| Read the PDFs | harvest() | clean sentences, and a drop log with the reason for each dropped line |
| Find causal claims | causenet() | one relation for each cause → effect pair |
| Group the names | pairwise_grouping(), clusterer() | groups of spans that mean the same construct |
| Name the groups | label_groups(), standardize_groups() | a name for each group, from its own words or from a thesaurus |
| Build the map | causal_map() | nodes, edges and the hidden edges |
| Show and save | draw_map(), save_map() | an offline HTML page, CSV tables and a GraphML file |
The rules behind the results
- Nothing is deleted. A step that drops a sentence, or hides an edge, marks it and records the reason. The drop log and the hidden edges are part of the result.
- A span stays as the author wrote it. The only change is a repair of the tokenizer output. That repair removes no word.
- Every result leads back to its source. An edge knows its relations. A relation knows its sentence, its page and its paper.
- A thesaurus is optional. The package includes the ELSST thesaurus. Every step that uses it has a twin that needs no thesaurus.
Status
Extraction, the grouping and naming of constructs, and the causal map work. The evaluation against a theory specification that people made by hand is planned.
Where to start
- Install the package.
- Run the quick start on the sample papers.
- Read the guide for the step that you need.
The tutorial notebook runs every function and every setting on 7 sample papers.
Install
theoryminer needs Python 3.10 or newer.
From PyPI
pip install theoryminer
The install includes UMAP (for clusterer(..., umap={...})) and tmsr-doi-downloader, an extra
download source. On Python 3.10, pip leaves out tmsr-doi-downloader. The default download
sources work on every version.
The newest code
To get the code on GitHub before the next release:
pip install "theoryminer @ git+https://github.com/rasoulnorouzi/theoryminer.git"
In a notebook
In Google Colab or Jupyter, put this line at the start of the first cell:
%pip install theoryminer
The tutorial notebook does this for you.
The models
The package downloads two models on first use. It keeps them in the Hugging Face cache, so later runs use the local copy.
| Model | First used by | Size |
|---|---|---|
rasoultilburg/SocioCausaNet | causenet() | about 430 MB |
sentence-transformers/all-mpnet-base-v2 | the grouping and naming functions | about 420 MB |
Without a network, set the environment variable HF_HUB_OFFLINE=1. The models must already be in
the cache.
The GPU
You do not need to set anything. causenet() uses device="auto": an NVIDIA GPU when PyTorch sees
one, else the GPU of an Apple Silicon Mac, else the CPU.
| System | What to do |
|---|---|
| Google Colab | Choose a GPU runtime: Runtime → Change runtime type → T4 GPU. |
| Linux with an NVIDIA GPU | Nothing. The PyTorch from pip supports the GPU. |
| Windows with an NVIDIA GPU | Install PyTorch from pytorch.org first, then theoryminer. The PyTorch from pip uses only the CPU on Windows. |
| Apple Silicon Mac | Nothing. When the model fails on the Apple GPU, the run moves to the CPU. |
The caches
| What | Where | Change it with |
|---|---|---|
| The text of each PDF page | <pdf>.pages.json beside each PDF | harvest(..., cache=False) |
| The thesaurus vectors | ~/.cache/theoryminer/taxonomy | the environment variable THEORYMINER_CACHE_DIR |
| The models | the Hugging Face cache | the environment variable HF_HOME |
When the package cannot write a cache folder, the functions still work. They compute the vectors again in the next session.
Quick start
This page runs the full pipeline on the 7 sample papers in the folder sample_files/ of the
repository. The numbers below come from that run on a laptop CPU.
To get the sample papers, clone the repository:
git clone https://github.com/rasoulnorouzi/theoryminer.git
cd theoryminer
The full run
from theoryminer import (harvest, causenet, clusterer, label_groups,
causal_map, draw_map, save_map)
data = harvest("sample_files") # 1. PDFs -> clean sentences
relations = causenet(data["sentences"]) # 2. sentences -> cause -> effect pairs
groups = clusterer(relations) # 3. group the spans that name the same construct
names = label_groups(groups) # 4. name each group from its own words
cmap = causal_map(relations, names) # 5. nodes and edges
draw_map(cmap) # 6. the interactive page
save_map(cmap, "outputs/my_map") # 7. CSV tables and a GraphML file
What each line gives
harvest()reads the 7 PDFs. It keeps 2,531 sentences and drops 1,865 rows: table rows, the reference sections, headings, page furniture. Each dropped row keeps its reason indata["dropped"]. Each sentence has the DOI and the title of its paper.causenet()finds 490 causal sentences and 945 relations. It takes about 2 minutes on a CPU. The first call downloads the model (about 430 MB).clusterer()puts the 1,197 different spans into 242 groups. A span that fits no group stays alone.label_groups()names each group with the words that its members repeat.causal_map()makes 50 nodes and 35 shown edges. It hides 849 weak edges and keeps them incmap["hidden"], each with its reason. By default an edge needs 2 sentences to show.draw_map()shows the page in a notebook. In a script it writescausal_map.html.save_map()writesnodes.csv,edges.csv,hidden.csvandmap.graphml.
Look at one relation
Each relation leads back to its source:
relation = relations[0]
print(relation["cause"], "->", relation["effect"])
print(relation["sentence"])
print(relation["doc_id"], "page", relation["page"], relation["doi"])
Next steps
- Use your own PDFs:
harvest("path/to/folder"). - Use the ELSST thesaurus for the names: see Group and name constructs.
- Find and download the papers that your papers cite: see A snowball round.
- See every setting with its default: Settings and defaults.
Read PDFs
harvest() reads one PDF, or every PDF in a folder, and returns clean sentences and a drop log.
from theoryminer import harvest
data = harvest("sample_files") # every PDF in the folder
data = harvest("book.pdf", pages="12-540") # one PDF, some pages only
The result has two lists:
| Key | What it holds |
|---|---|
data["sentences"] | one dict per kept sentence |
data["dropped"] | one dict per dropped row, with its drop_reason |
What a sentence holds
| Key | Meaning |
|---|---|
doc_id | the file name without .pdf |
page | the page number, as in a PDF viewer |
sent_id | a unique id, for example d000p0012s003: document 0, paragraph 12, sentence 3 |
section | the last heading before the sentence |
raw | the sentence as the sentence splitter gave it |
clean | the sentence after the later steps; causenet() reads this text |
flag | "", or a flag for a doubtful sentence (see below) |
doi, title | the DOI and the title of the paper, or "" |
The cleaning steps
The steps run in this order. You can change the list with steps=.
| Step | What it does |
|---|---|
fix_unicode | replaces curly quotes, long dashes, odd spaces, and removes invisible marks |
strip_markdown | removes Markdown links and emphasis that the PDF extractor adds |
drop_running_heads | drops short lines that repeat 3 or more times, such as a chapter header |
drop_junk_lines | drops structural lines: headings, table rows, page numbers, the reference section |
join_paragraphs | joins the lines of a paragraph, and a word that a hyphen cut. It joins a paragraph that a page break cut when the first part has no final punctuation and the next part starts in lowercase. |
split_sentences | splits the paragraphs into sentences |
strip_citations | removes citations such as “(Ryan & Deci, 2000)” from clean |
strip_stats | removes statistics such as “(p < .05)” from clean |
drop_junk_sentences | drops sentences that are not research text |
filter_length | drops sentences that are too short or too long |
To switch off one step, give the list without it:
from theoryminer import DEFAULT_STEPS
steps = [step for step in DEFAULT_STEPS if step != "strip_stats"]
data = harvest("sample_files", steps=steps)
You can also add a function of your own. It takes the list of row dicts and returns it. The tutorial notebook, section 1.3b, shows how.
Why a row is dropped
harvest() never deletes a row. A step marks it as dropped and writes the reason.
| Reason | The row is |
|---|---|
running_head | a line of 90 characters or fewer that repeats 3 or more times |
reference_section | a line inside a references, bibliography, notes or acknowledgements section |
heading | a heading; its text becomes the section of the next sentences |
table_row | a row of a table |
page_number | a line that holds only a page number |
chapter_marker | a line that holds only “CHAPTER” or “PART” |
front_back_matter | a line that holds only “Index”, “Contents” or “Appendix” |
doi_url | a line that starts with a DOI or a web address |
ref_fragment | one word with a digit and no space, a piece of a reference |
low_alpha | a line or a sentence with too few letters |
licence_text | the licence or copyright text of a journal |
caption_or_keywords | a text that starts with “Keywords:”, “Source:”, “Note:” or “Abstract:”, or a caption such as “Figure 1” or “Table 2” |
index_entry | an entry of a book index |
spaced_letters | text with spaces between its letters, such as “R e s u l t s” |
no_lowercase | a sentence without a lowercase letter |
digit_heavy | a sentence with too many digits |
too_short | a sentence with fewer than min_words words (default 4) |
too_long | a sentence with more than max_words words (default 80) |
empty_page | a page without text |
not_pdf, unreadable_pdf | a file in the folder that is not a PDF, or that cannot be read |
Flags
A flag marks a doubtful sentence. The sentence stays in data["sentences"].
| Flag | Meaning |
|---|---|
front_matter | the PDF extractor mixed journal sidebar text into the sentence: an editor name, a date, an e-mail address |
fragment | the sentence has no final punctuation |
long | the sentence has more than 80 words. With the default max_words=80, these sentences are dropped as too_long, so the flag shows only when you raise max_words. |
To keep the flagged sentences away from the model:
sentences = [s for s in data["sentences"] if not s["flag"]]
Pages
Page 1 is the first page, as in a PDF viewer.
harvest("book.pdf", pages=12) # one page
harvest("book.pdf", pages="12-540") # a range
harvest("book.pdf", pages="3,7,9-12") # a list of pages and ranges
harvest("book.pdf", skip_pages=[4, 5]) # every page except 4 and 5
For a folder, the same pages apply to every PDF.
The page cache
Text extraction is the slow part. harvest() writes the text of each PDF to <pdf>.pages.json
beside the PDF. The next run reads that file. Use cache=False to read every PDF again and write
no file.
A bad file
For one PDF, a missing or broken file raises a ValueError. For a folder, a bad file does not stop
the run. The drop log gets one row for the file, with page 0 and the reason not_pdf or
unreadable_pdf. harvest() reads the folder only, not its subfolders.
See the full list of settings in the reference of harvest().
DOIs and references
Two functions read DOIs from PDFs:
| Function | What it finds | Use it to |
|---|---|---|
extract_dois() | the DOI and the title of each paper itself | label each paper on the map |
extract_references() | the DOIs that each paper cites | find the papers for the next round of a snowball search |
The DOI of each paper
You do not need extract_dois() for the map. harvest() already puts the DOI and the title on
each sentence (find_dois=True), and causenet() copies them to each relation.
Use extract_dois() when you want the list on its own:
from theoryminer import extract_dois
rows = extract_dois("sample_files", save="outputs") # writes outputs/dois.csv
Each row has doc_id, file, doi, source, title, page, status and note.
The DOI comes from the most certain source, in this order:
downloads.csv, the log thatdownload_papers()writes into the folder.- The file name of a PDF that
download_papers()saved, because it is made from the DOI. - The subject or keywords field of the PDF metadata.
- The first DOI on page 1, else on page 2. Page 1 is sometimes a cover page.
The source column tells which rule gave the DOI. On 21 papers with a known DOI, this order found 20.
The title is the largest text on page 1 that has at least 4 words. A shorter large text is usually a journal name, such as “PLOS ONE”. When no text passes, the title comes from the PDF metadata.
The DOIs in the references
from theoryminer import extract_references
refs = extract_references("sample_files", save="outputs") # writes outputs/references.csv
Each row has doi, n_seeds (how many of your papers cite it) and cited_by (which papers).
The most cited DOIs come first.
The function repairs a DOI that the PDF cut at a line end, for example
10.1007/s1096 4-015-0354-5. It leaves out the DOI of the paper itself and the DOIs of its parts
(figures, tables). On the 7 sample papers it found 215 DOIs, and 214 of them exist at doi.org.
A reference that prints no DOI cannot be found. The journal of one sample paper prints no DOIs in its references, so that paper gives 0 DOIs.
Give the result to download_papers() to get the PDFs.
Download papers
download_papers() downloads the PDFs of a list of DOIs into a folder. It gives one status row
for each DOI.
from theoryminer import extract_references, download_papers
refs = extract_references("seed_papers")
report = download_papers(refs, "round_2", email="you@your-uni.edu")
dois can be the output of extract_references(), a list of DOI strings, or the path of a CSV
file with a doi column.
How it finds a PDF
The function asks OpenAlex for the licence of each paper first. With licences=["cc-by"] it
downloads only the CC BY papers, and it never contacts the other papers’ sites.
Then it tries the sources in turn and stops at the first real PDF:
| Source | What it does |
|---|---|
openalex | the PDF links and the repository pages of the open-access copies in OpenAlex |
publisher | the direct PDF link of the publisher, from a plugin for that publisher |
pmc | the PDF in the open-access data of PubMed Central |
europe_pmc | the free PDF links that Europe PMC lists |
unpaywall | the PDF links of Unpaywall; it needs email |
semantic_scholar | the open-access PDF link of Semantic Scholar |
crossref | the PDF links that the publisher gives to Crossref |
preprint | the open OSF preprint of the paper, when Crossref links one |
landing_page | the page that doi.org opens, and the PDF link on it |
A link can give the PDF or a web page. For a page, the function follows its citation_pdf_url
tag, the tag that publishers write for Google Scholar. The function keeps a file only when it
is a real PDF. It waits 1 second between two requests to the same site, and it obeys robots.txt.
Give email when you can. OpenAlex and Crossref answer faster with it, and Unpaywall needs it.
The publisher plugins
A plugin knows how one publisher builds its PDF links, in the same way as a Zotero translator.
It turns a DOI into the direct link. The package has plugins for Springer (also BMC and Palgrave)
and Frontiers. Each plugin is a few lines in theoryminer/download/publishers.py.
A plugin is useful only when the publisher gives the PDF to a plain request. Wiley, Taylor & Francis, SAGE, MDPI, Annual Reviews and Emerald give their PDFs only to a browser that passes a bot check, so they have no plugin. Their open papers often come through the other sources, as a copy in a repository or in PubMed Central.
The result
| Key | Meaning |
|---|---|
doi | the DOI, in lowercase |
status | downloaded, already_there, skipped_licence, not_found or error |
source | the source that gave the PDF |
licence | the OpenAlex licence, none or unknown |
file | the file name of the PDF, made from the DOI |
note | for a missed DOI, the reason from each source |
The function also writes downloads.csv into the folder. harvest() reads that file to know the
DOI of each PDF. A PDF that is already in the folder is not downloaded again, so you can run the
function again after a stop.
What to expect
We tested the sources on 300 DOIs: 100 references of the sample papers and 200 random articles in psychology and the social sciences.
| Result | |
|---|---|
| Open-access papers downloaded | 101 of 164 (62 %) |
| Closed papers downloaded | 4 of 135 (3 %) |
| On the 100 references, against version 0.2 | 33 correct PDFs, against 25 |
A closed paper has no legal free copy, so no open source can give it. Many publishers (Wiley,
Elsevier, SAGE, Taylor & Francis, MDPI) also block programs, even for their open papers. These
two cases are most of the misses. The note column tells you the reason for each DOI.
The benchmark is in benchmarks/downloader/. Run it again with
python benchmarks/downloader/run_benchmark.py --label my_run --email you@your-uni.edu.
What the downloader does not do
The downloader gets open-access copies only. It does not log in to a publisher site, and it does
not use a browser. A paper that is only behind a paywall stays not_found. Get such a paper
through your library, and put the PDF into the folder with the others. harvest() reads it, and
it finds the DOI in the PDF metadata or on the first two pages.
tmsr-doi-downloader
Versions 0.2.x used the package tmsr-doi-downloader. Since 0.3.0 the default sources replace
it: on the 100 references it added one paper and doubled the run time. It is still available as
an extra source: sources=SOURCES + ["tmsr-doi-downloader"].
Find causal claims
causenet() finds the cause → effect pairs in the sentences.
from theoryminer import harvest, causenet
data = harvest("sample_files")
relations = causenet(data["sentences"])
The model rasoultilburg/SocioCausaNet reads one sentence at a time. It decides whether the
sentence is causal, marks the cause spans and the effect spans, and links each cause to each effect.
One sentence can give more than one relation.
A relation
| Key | Meaning |
|---|---|
rel_id | a unique id: the sent_id plus r and a number, for example d000p0012s003r01 |
sent_id, doc_id, page | from the sentence |
cause | the cause span, in lowercase |
effect | the effect span, in lowercase |
sentence | the clean sentence |
doi, title | the paper, from harvest() |
A span is the words of the sentence, as the author wrote them. The function repairs the spaces that the tokenizer changes, and it removes no word. On the sample papers, 97 % of the relations have both spans word for word in their sentence.
You can also give a plain list of strings. Then doc_id is "input" and page is 0.
Settings
| Setting | Default | What it does |
|---|---|---|
threshold | 0.8 | the score that a pair needs. A lower value gives more relations and more errors. |
decision | "span_only" | a sentence is causal when it has a cause span and an effect span. "cls+span" also needs the sentence classifier (the default before 0.2.0). "cls_only" uses only the classifier. |
mode | "neural" | the model scores every cause → effect pair. "auto" keeps every pair without a score when a sentence has only one cause or only one effect. |
avoid_ambiguous | True | skips a relation whose span only points to another sentence, such as “this” or “it”. The summary prints how many it skipped. |
device | "auto" | an NVIDIA GPU, else the Apple GPU, else the CPU |
batch_size | 64 | sentences per step. Use a smaller number when the memory of the GPU is too small. |
Speed
| Run | Sentences | Time on a laptop CPU |
|---|---|---|
| the 7 sample papers | 2,531 | about 2 minutes |
| one snowball round, 14 papers | 4,450 | about 4 minutes |
A GPU is much faster. In Google Colab, choose a GPU runtime. The function writes no file, so keep the result in a variable. To keep it for a later session, save it as JSON:
import json
with open("outputs/relations.json", "w", encoding="utf-8") as fh:
json.dump(relations, fh)
Leave out the flagged sentences
harvest() flags doubtful sentences but keeps them. To keep them away from the model:
sentences = [s for s in data["sentences"] if not s["flag"]]
relations = causenet(sentences)
See every setting in the reference of causenet().
Group and name constructs
Authors use many names for one construct: “loneliness”, “feelings of loneliness”, “perceived isolation”. Before the map, the spans that mean the same thing must become one node. The package has three routes.
| Route | Functions | Needs a thesaurus | A node is |
|---|---|---|---|
| Group, then name from the group’s own words | clusterer() or pairwise_grouping(), then label_groups() | no | a group, named by its keywords |
| Group, then name with a thesaurus | clusterer() or pairwise_grouping(), then standardize_groups() | yes (ELSST is included) | the best thesaurus concept of a group |
| Name each span with a thesaurus | standardize_constructs() | yes | the best thesaurus concept of a span |
Each route gives the names that causal_map() needs.
from theoryminer import clusterer, label_groups, causal_map
groups = clusterer(relations)
names = label_groups(groups)
cmap = causal_map(relations, names)
All the functions read the cause and effect spans of the relations. You can also give them a plain
list of span strings. They use the sentence model all-mpnet-base-v2 by default. Every similarity
is a cosine.
Make groups
| Function | Method | Default |
|---|---|---|
clusterer() | HDBSCAN on the span vectors. A span that fits no group stays alone (noise). | min_cluster_size=2, min_samples=2, no UMAP |
pairwise_grouping() | joins the spans whose cosine is at or above a threshold | threshold=0.69, linkage="average" |
The result of both is a dict: {span: {"group": id, "central": span}}. The central span is the
member that is most like the other members.
Use linkage="average", the default since 0.2.0. With "single", one shared neighbour joins two
groups, and long chains form. When A is like B and B is like C, A and C go into one group, also
when they are far apart.
Warning: pairwise_grouping() computes the distance between every pair of spans. Its memory
grows with the square of the number of spans: about 10 GB for 50,000 spans. For a large corpus,
use clusterer().
Name the groups without a thesaurus
label_groups() names each group with the words that its members repeat and that the other
groups do not use (class-based TF-IDF). It needs no model and no thesaurus.
by | The name is |
|---|---|
"keywords" (default) | the 3 best keywords, joined by a space |
"medoid" | the central span |
"both" | the central span, then |, then the keywords |
Name with the ELSST thesaurus
ELSST (the European Language Social Science Thesaurus, release 5) is part of the package. Its licence is CC BY-SA 4.0.
standardize_constructs() gives each span the 5 best concepts. standardize_groups() gives each
group the 5 best concepts. A group of spans gets one name from all its members.
| Setting | Default | What it does |
|---|---|---|
strategy | "enriched" | the text that stands for a concept: its label, other labels, scope note and path. See Thesaurus strategies. |
top | 5 | the number of concepts for each span or group |
leaves_only | False | False also allows broad concepts, such as TRUST. True allows only the most specific concepts. |
threshold | None | None gives every span its best concept. A number leaves a span without a concept when its best score is lower. |
name_by (groups only) | "mean_sim" | how a group is compared with a concept: the mean of its members ("mean_sim"), their mean vector ("centroid"), or the central span ("medoid") |
taxonomy | "elsst" | the thesaurus. A path to another SKOS .rdf or .rdf.gz file also works. |
The first run computes the vectors of the thesaurus and caches them in
~/.cache/theoryminer/taxonomy. Later runs load them.
Which route to use
- Start with
clusterer()andlabel_groups(). They need no thesaurus, and the names use the words of your papers. - Use
standardize_groups()when you want names from a shared vocabulary, so that you can compare your map with other work. - Use
standardize_constructs()to check how one span maps to the thesaurus.
The causal map
Three functions turn the relations and the names into a map:
| Function | What it does |
|---|---|
causal_map() | builds the nodes and the edges |
draw_map() | shows the map as an interactive page |
save_map() | writes the map as CSV tables and a GraphML file |
from theoryminer import causal_map, draw_map, save_map
cmap = causal_map(relations, names)
draw_map(cmap)
save_map(cmap, "outputs/my_map")
names is the output of label_groups(), standardize_groups() or standardize_constructs().
See Group and name constructs.
Nodes and edges
A node is a construct: a named group, or a thesaurus concept. An edge goes from a cause node to an
effect node. It counts the papers, the sentences and the relations that claim it, and it keeps the
rel_ids of those relations. So each edge leads back to its sentences.
| Edge key | Meaning |
|---|---|
n_papers, n_sentences, n_relations | how many papers, sentences and relations claim the edge |
rel_ids, doc_ids | the relations and the papers behind the edge |
self_loop | True when the cause and the effect are the same node |
reciprocal | True when the opposite edge also exists (a two-way pair) |
direction | one-way, or for a two-way pair dominant, minority or contested |
drop_reason | "" for a shown edge, else why the edge is hidden |
A node has a role, from its shown edges: cause only, effect only or cause and effect.
Which edges show
causal_map() hides a weak edge, but it deletes nothing. A hidden edge goes to cmap["hidden"]
with its reason.
| Setting | Default | A shown edge needs |
|---|---|---|
min_papers | 1 | at least this many papers. 1 also suits one long book. |
min_sentences | 2 | at least this many sentences. Use 1 to show every edge. |
A hidden edge has the drop_reason below_min_papers or below_min_sentences.
Two-way pairs
When papers claim both A → B and B → A, both edges stay. min_share (default 0.67) decides how the
pair is read. When one side has at least 2/3 of the papers, that side is dominant and the other
side is minority. Otherwise both sides are contested. A minority edge shows as a dashed line.
The page
draw_map() writes one HTML file that works offline. In a notebook it shows in the cell. In a
script it writes causal_map.html, or the file that you give with save=.
On the page you can:
- hover over a node or an edge, and click an edge to read all its sentences, with the DOI of each paper (else its title, else its file name);
- search for a construct: the page shows only the matches and their neighbours;
- filter by paper, by role, by two-way pairs, with the
min papersandmin sentencessliders, and with the self-loop switch; - drag, zoom and reset;
- save a picture as PNG, JPG or SVG: the whole map, the current view, or one node with its neighbours.
The page receives every edge, also the hidden ones. Its sliders start at the values of
causal_map(), so you can show a hidden edge on the page.
Warning: a large map opens slowly. The page computes the layout in the browser. In our test, a
map with 1,000 shown nodes took about 78 seconds to open. A map with 3,000 nodes did not open in
10 minutes. Keep the map small with min_papers and min_sentences. Most readers cannot read more
than a few hundred nodes anyway.
The files
save_map(cmap, folder) writes:
| File | What it holds |
|---|---|
nodes.csv | one row per node, also the nodes that no shown edge touches |
edges.csv | one row per shown edge |
hidden.csv | one row per hidden edge, with its drop_reason |
map.graphml | every node and edge, for Gephi or Cytoscape Desktop |
In the CSV files, a list (members, doc_ids, rel_ids) is one text, joined by |.
A snowball round
A snowball search starts from seed papers and follows their references. One round has four steps:
- Find the DOIs in the references of the seed papers.
- Download the PDFs of those DOIs.
- Read the new papers and draw their map.
- Find the references of the new papers: the DOIs for the next round.
from theoryminer import (extract_references, download_papers, harvest, causenet,
pairwise_grouping, label_groups, causal_map, draw_map)
# 1. The references of the seed paper
refs = extract_references("sample_files/schutz_2025_loneliness_social_support.pdf")
# 2. Their open-access PDFs, CC BY only
report = download_papers(refs, "outputs/round_2", licences=["cc-by"], email="you@your-uni.edu")
# 3. The map of the new papers
data = harvest("outputs/round_2")
relations = causenet(data["sentences"])
names = label_groups(pairwise_grouping(relations))
cmap = causal_map(relations, names)
draw_map(cmap, save="outputs/round_2_map.html")
# 4. The DOIs for round 3
next_refs = extract_references("outputs/round_2", save="outputs/round_3_dois.csv")
What the tutorial run gave
| Step | Result |
|---|---|
| 1. References of the seed | 74 DOIs |
| 2. Download, CC BY only | 45 papers left out by their licence; 26 of the 29 CC BY papers in the folder |
| 3. The new papers | 4,450 sentences, 1,555 relations, a map with 72 nodes and 66 shown edges |
| 4. References of the new papers | 472 DOIs; 26 of them cited by 2 or more papers |
licences=["cc-by"] keeps only the papers whose text may be shown and shared. Leave it out to
download every open-access paper.
The DOIs that more papers cite come first in the output of extract_references(). They are a good
start for the next round.
Notes
- The download writes
downloads.csvinto the folder.harvest()reads it, so each sentence knows the DOI of its paper, also when the PDF does not print its DOI. - A stopped download can go on: a PDF that is already in the folder is not downloaded again.
- See Download papers for the sources and the limits.
Reference
One page for each public function. The text comes from the docstring in the code.
Read PDFs
| function | what it does |
|---|---|
harvest() | Read one PDF, or every PDF in a folder, and return the clean sentences and a drop log. |
extract_dois() | Find the DOI and the title of each paper in one PDF, or in every PDF in a folder. |
extract_references() | Find the DOIs that the seed papers cite, in one PDF or in every PDF in a folder. |
Download papers
| function | what it does |
|---|---|
download_papers() | Download the open-access PDFs of the DOIs into a folder. Return one status row per DOI. |
Find causal claims
| function | what it does |
|---|---|
causenet() | Find the cause -> effect pairs in the sentences. |
Group and name constructs
| function | what it does |
|---|---|
pairwise_grouping() | Group the spans that are similar, pair by pair. |
clusterer() | Group the spans by density, with HDBSCAN. |
label_groups() | Give each group of spans a name. No thesaurus, no model. |
standardize_constructs() | Find the closest thesaurus concepts for each span. |
standardize_groups() | Find the closest thesaurus concepts for each group of spans. |
Draw the causal map
| function | what it does |
|---|---|
causal_map() | Build the causal map from the relations and the names of their spans. |
draw_map() | Draw the causal map as an interactive page. |
save_map() | Write the causal map to a folder: three CSV tables and one GraphML file. |
harvest()
Read one PDF, or every PDF in a folder, and return the clean sentences and a drop log.
harvest(
source: str,
pages: str | int | list[int] | None = None,
skip_pages: str | int | list[int] | None = None,
steps: list[str | Callable] | None = None,
min_words: int = 4,
max_words: int = 80,
cache: bool = True,
find_dois: bool = True,
) -> dict
Source: theoryminer/prepare/harvest.py
The function reads the text of each page, runs the cleaning steps in order, and splits
the text into sentences. A step never deletes a row: it marks the row as dropped and
gives the reason. A doubtful sentence keeps its row and gets a flag.
For one PDF, a bad file raises a ValueError. For a folder, a bad file does not stop the
run: the drop log gets one row for the file, with page 0 and the reason “not_pdf” or
“unreadable_pdf”. The function looks in the folder only, not in its subfolders.
It writes one file per PDF, the page cache (see cache).
Arguments
source
The PDF file, or the folder of PDF files, to read.
- Values: the path of one PDF file, or the path of a folder.
- Default: none. Give a path.
pages
The pages to read. Page 1 is the first page, as in a PDF viewer.
- Values: one page (12), a text (“12-540” or “3,7,9-12”), a list of pages ([3, 5, 9]), or None. For a folder, the same pages apply to every PDF.
- Default
None: read every page.
skip_pages
The pages to leave out, after pages has selected its pages.
- Values: the same forms as
pages. - Default
None: leave out no page.
steps
The cleaning steps, in the order in which they run.
- Values: a list of step names from STEPS, of your own functions, or of both. A function of your own takes a list of row dicts and returns a list of row dicts. To switch off one step, give DEFAULT_STEPS without that name.
- Default
None: run DEFAULT_STEPS, the 10 steps in this order: fix_unicode, strip_markdown, drop_running_heads, drop_junk_lines, join_paragraphs, split_sentences, strip_citations, strip_stats, drop_junk_sentences, filter_length.
min_words
The fewest words that a sentence can have (the step filter_length).
- Values: a whole number, 1 or more.
- Default
4: a sentence with 3 words or fewer is dropped as “too_short”.
max_words
The most words that a sentence can have (the step filter_length).
- Values: a whole number, min_words or more.
- Default
80: a sentence with 81 words or more is dropped as “too_long”.
cache
Whether to keep the text of the pages on disk.
- Values: True or False.
- Default
True: write <pdf>.pages.json beside each PDF, and read it on the next run. The next run then skips the slow text extraction. False reads every PDF again and writes no file.
find_dois
Whether to find the DOI and the title of each paper, for each sentence.
- Values: True or False.
- Default
True: find them, as extract_dois() does. The DOI comes from the most certain source: the download log (downloads.csv in the folder), the file name of a download_papers() PDF, the PDF metadata, then the first DOI on pages 1 and 2. causenet() copies them to each relation, and draw_map() shows them. False skips this work and gives “” for both.
Returns
A dict with two lists:
- “sentences”: one dict per kept sentence, with the keys
| key | meaning |
|---|---|
doc_id | the file name without “.pdf” |
page | the page number |
sent_id | for example “d000p0012s003”: document 0, paragraph 12, sentence 3 |
section | the last heading before the sentence |
raw | the sentence as the splitter gave it, before the later steps |
clean | the sentence that causenet() reads |
flag | “” for no flag, else “front_matter”, “fragment” or “long” |
doi | the DOI of the paper, in lowercase, or “” |
title | the title of the paper, or “” |
- “dropped”: one dict per dropped row, with the keys doc_id, page, section, drop_reason and text.
Raises
ValueError: The source does not exist, the folder has no PDF, or the one PDF cannot be read. Also for a wrong setting: a page selection that is not a page, an unknown step name, min_words below 1, or max_words below min_words.
Example
>>> data = harvest("sample_files")
>>> len(data["sentences"]), data["sentences"][0]["sent_id"]
(2531, 'd000p0000s000')
extract_dois()
Find the DOI and the title of each paper in one PDF, or in every PDF in a folder.
extract_dois(
source: str,
save: str | None = None,
first_pages: int = FIRST_PAGES,
cache: bool = True,
) -> list[dict]
Source: theoryminer/prepare/dois.py
The DOI rule: the first DOI on page 1, else the first DOI on the next pages, up to
first_pages. The title rule: the largest font on page 1 with at least MIN_TITLE_WORDS
words, else the title in the PDF metadata. A bad file does not stop the run: it gets a
row with the status “not_pdf” or “unreadable_pdf”. The function reads the same page
cache as harvest(). It writes a CSV file only when you give save.
Arguments
source
The PDF file, or the folder of PDF files, to read.
- Values: the path of one PDF file, or the path of a folder. The function looks in the folder only, not in its subfolders.
- Default: none. Give a path.
save
Where to write the rows as a CSV file.
- Values: None, a path that ends with “.csv”, or a folder (then <folder>/dois.csv). A missing folder is made.
- Default
None: write no file.
first_pages
The last page on which to look for the DOI.
- Values: a whole number, 1 or more. A larger value can find a DOI that is not on the first pages, but also the DOI of a cited paper in the reference list.
- Default
2: look on page 1, then on page 2 (page 1 is sometimes a cover page).
cache
Whether to use the page cache <pdf>.pages.json beside each PDF.
- Values: True or False.
- Default
True: read the cache when it exists, else read the PDF and write the cache. False reads every PDF again and writes no cache.
Returns
A list of dicts, one per file, in file-name order, with the keys
| key | meaning |
|---|---|
doc_id | the file name without “.pdf”, as in harvest() |
file | the path of the file |
doi | the DOI in lowercase, or “” when none was found |
title | the title, or “” when none was found |
page | the page of the DOI, or 0 when none was found |
status | “found”, “not_found”, “not_pdf” or “unreadable_pdf” |
note | why a file has no DOI, else “” |
Raises
ValueError: The source does not exist, or the folder has no PDF.
Example
>>> rows = extract_dois("sample_files/alrabai_2021_autonomy_supportive_teaching.pdf")
>>> rows[0]["doi"], rows[0]["status"]
('10.3389/fpsyg.2021.728657', 'found')
extract_references()
Find the DOIs that the seed papers cite, in one PDF or in every PDF in a folder.
extract_references(
source: str,
save: str | None = None,
cache: bool = True,
) -> list[dict]
Source: theoryminer/prepare/dois.py
The function reads every page of each seed paper and finds each DOI. It repairs a DOI
that the PDF cut at a line end (“10.1007/s1096 4-015-0354-5”). It leaves out the DOI of
the seed itself and the DOIs of its parts (figures, tables). A reference that prints no
DOI cannot be found. A bad file does not stop the run: the summary names it.
It writes a CSV file only when you give save.
Arguments
source
The seed paper, or the folder of seed papers, to read.
- Values: the path of one PDF file, or the path of a folder. The function looks in the folder only, not in its subfolders.
- Default: none. Give a path.
save
Where to write the rows as a CSV file, with the columns doi, n_seeds, cited_by. A download tool can read its “doi” column.
- Values: None, a path that ends with “.csv”, or a folder (then <folder>/references.csv). A missing folder is made.
- Default
None: write no file.
cache
Whether to use the page cache <pdf>.pages.json beside each PDF.
- Values: True or False.
- Default
True: read the cache when it exists, else read the PDF and write the cache. False reads every PDF again and writes no cache.
Returns
A list of dicts, one per unique reference DOI, with the keys
| key | meaning |
|---|---|
doi | the DOI, in lowercase |
n_seeds | the number of seed papers that cite it |
cited_by | the doc_ids of those seed papers |
The DOIs that most seeds cite come first.
Raises
ValueError: The source does not exist, or the folder has no PDF.
Example
>>> refs = extract_references("sample_files")
>>> refs[0]["doi"], refs[0]["n_seeds"]
('10.1037/0003-066x.55.1.68', 2)
download_papers()
Download the open-access PDFs of the DOIs into a folder. Return one status row per DOI.
download_papers(
dois: list | str,
output_dir: str,
licences: list[str] | None = None,
email: str | None = None,
sources: list[str] | None = None,
respect_robots: bool = True,
core_api_key: str | None = None,
serpapi_key: str | None = None,
save: str | None = None,
) -> list[dict]
Source: theoryminer/download/papers.py
The function asks OpenAlex for the licence of each paper. With licences, it downloads
only the papers with one of those licences. Then it tries the sources in turn until one
gives a real PDF. A link can give the PDF or a page; for a page, the function follows its
citation_pdf_url meta tag once. It waits 1 second between two requests to the same site.
A PDF that is already in the folder is not downloaded again, so a stopped run can go on.
A failed DOI does not stop the run. The function writes the PDFs and the log downloads.csv
into output_dir (the log keeps the rows of earlier runs, and harvest() reads it to know the
DOI of each file), and a report file when you give save. It gets open-access copies only:
it does not log in to a publisher site.
Arguments
dois
The papers to download.
- Values: the rows of extract_references() (dicts with a “doi” key), a list of DOI strings, or the path of a CSV file with a “doi” column. A text is always read as the path of a CSV file, not as one DOI.
- Default: none. Give the DOIs.
output_dir
The folder for the PDFs.
- Values: the path of a folder. A missing folder is made. The file name is the DOI with “_” for each unsafe character.
- Default: none. Give a folder.
licences
The licences to download.
- Values: None, or a list of OpenAlex licences: “cc-by”, “cc-by-sa”, “cc-by-nc”, “cc-by-nd”, “cc-by-nc-sa”, “cc-by-nc-nd”, “public-domain”, “other-oa”, “publisher-specific-oa”, “none” and “unknown”.
- Default
None: download every paper that has an open-access copy, whatever its licence.
email
Your email address. OpenAlex and Crossref answer faster with it (“polite pool”), and Unpaywall needs it.
- Values: None, or a real email address (Unpaywall refuses a made-up address).
- Default
None: no email. The function skips Unpaywall, and tmsr-doi-downloader uses the environment variable UNPAYWALL_EMAIL when it is set.
sources
The sources to try, in this order.
- Values: a list of one or more of “openalex” (the PDF links and repository pages of the open-access copies in OpenAlex), “publisher” (the publisher’s direct PDF link, from a per-publisher plugin), “pmc” (the PDF in the PubMed Central open-access data of NCBI), “europe_pmc” (the free PDF links that Europe PMC lists), “unpaywall” (the PDF links of Unpaywall; it needs
email), “semantic_scholar” (the open-access PDF link of Semantic Scholar), “crossref” (the PDF links that the publisher gives to Crossref), “preprint” (the open OSF preprint of the paper, from the Crossref relation), “landing_page” (the page that doi.org opens, and its PDF link), “tmsr-doi-downloader” (the package tmsr-doi-downloader; Python 3.11 or newer). - Default
None: the nine open sources above, from “openalex” to “landing_page”, in that order. The default leaves out “tmsr-doi-downloader”. Add it with this setting when the open sources miss a paper.
respect_robots
Whether to obey the robots.txt file of each site.
- Values: True or False. Set False only when you have the right to download from the sites, for example by an agreement of your institution. The wait between requests stays. tmsr-doi-downloader always obeys robots.txt.
- Default
True: skip every link that robots.txt does not allow.
core_api_key
A free key from core.ac.uk, for CORE in tmsr-doi-downloader.
- Values: None, or the key as text.
- Default
None: use the environment variable CORE_API_KEY when it is set, else skip CORE.
serpapi_key
A key of SerpAPI (a paid service), for Google Scholar in tmsr-doi-downloader.
- Values: None, or the key as text.
- Default
None: use the environment variable SERPAPI_KEY when it is set, else skip Google Scholar.
save
Where to write the report rows as a CSV file.
- Values: None, a path that ends with “.csv”, or a folder (then <folder>/downloads.csv).
- Default
None: write no report file.
Returns
A list of dicts, one per DOI, with the keys
| key | meaning |
|---|---|
doi | the DOI, in lowercase |
status | “downloaded”, “already_there”, “skipped_licence”, “not_found” or “error” |
source | for a download, the source that gave the PDF (a value of sources), else “” |
licence | the OpenAlex licence, “none” (no open-access copy) or “unknown” (OpenAlex does not know the DOI); “” for “already_there” and “error” |
file | the file name of the PDF, or “” when there is no PDF |
note | why a DOI has no PDF (“<source>: <reason>” for each source), else “” |
Raises
ValueError: whensourcesis empty or holds a name that is not one of the sources above.
Example
>>> refs = extract_references("sample_files")
>>> report = download_papers(refs, "outputs/round_2", licences=["cc-by"])
>>> report[0]["status"], report[0]["source"]
('downloaded', 'openalex')
causenet()
Find the cause -> effect pairs in the sentences.
causenet(
sentences: list,
threshold: float = 0.8,
mode: str = 'neural',
decision: str = 'span_only',
batch_size: int = 64,
device: str = 'auto',
avoid_ambiguous: bool = True,
) -> list[dict]
Source: theoryminer/causenet.py
The model rasoultilburg/SocioCausaNet reads one sentence at a time. It decides whether the sentence is causal, marks the cause spans and the effect spans, and links each cause to each effect. The function then repairs each span with tidy_span(), which removes no word, and gives each relation the path back to its sentence. The first call loads the model (about 440 MB, downloaded on first use). The function writes no file. Keep the result in a variable: a large corpus takes long on a CPU.
Arguments
sentences
The sentences to read.
- Values: the rows of harvest()[“sentences”] (the function reads “clean”, “sent_id”, “doc_id” and “page”), or a plain list of strings (then doc_id is “input”, page is 0, and sent_id is “s0000”, “s0001”, …).
- Default: none. Give the sentences.
threshold
The score that a cause -> effect pair needs to become a relation.
- Values: a number from 0 to 1. A lower value gives more relations and more errors.
- Default
0.8: keep a pair with a score of 0.8 or more.
mode
How the model links each cause to each effect.
- Values: “neural”: the model scores every cause -> effect pair, and
thresholddecides. “auto”: when a sentence has only one cause or only one effect, every pair is kept without a score; else as “neural”. - Default “neural”.
decision
The rule that makes a sentence causal.
- Values: “span_only”: the sentence has a cause span and an effect span. “cls+span”: also the sentence classifier must say causal (the default before 0.2.0). “cls_only”: only the sentence classifier decides.
- Default “span_only”.
batch_size
The number of sentences in one model call.
- Values: a whole number, 1 or more. On a GPU a larger value is often faster. Lower it after a GPU “out of memory” error.
- Default 64.
device
Where the model runs.
- Values: “auto”, “cpu”, “cuda” (an NVIDIA GPU) or “mps” (the GPU of an Apple Silicon Mac). If the model fails on an Apple GPU, it moves to the CPU and the run goes on.
- Default
"auto": an NVIDIA GPU when torch sees one, else the Apple GPU, else the CPU.
avoid_ambiguous
Whether to skip a relation whose cause or effect only points to another sentence: “this”, “it”, “such things” (the rule is harmonizer.spans.is_vague_span).
- Values: True or False.
- Default
True: skip these relations. The summary prints their count and examples. False keeps every relation.
Returns
A list of dicts, one per relation, with the keys
| key | meaning |
|---|---|
rel_id | sent_id + “r” + the number of the relation in its sentence: “d000p0012s003r01” |
sent_id, doc_id, page | from the sentence row |
cause | the cause span, in lowercase |
effect | the effect span, in lowercase |
sentence | the clean sentence |
doi | the DOI of the paper, from harvest(), or “” (also for a plain list of strings) |
title | the title of the paper, from harvest(), or “” |
Raises
ValueError: An unknown mode, decision or device, a threshold outside 0 to 1, a batch_size below 1, or a device that torch cannot see (“cuda” without an NVIDIA GPU).
Example
>>> relations = causenet(["Social support increases feelings of security and well-being."])
>>> [(r["cause"], r["effect"]) for r in relations]
[('social support', 'feelings of security and well-being')]
pairwise_grouping()
Group the spans that are similar, pair by pair.
pairwise_grouping(
items: list,
embeddings: str = 'allmpnet',
threshold: float = 0.69,
linkage: str = 'average',
) -> dict
Source: theoryminer/harmonizer/pairwise.py
The function turns each span into a vector and joins spans whose cosine reaches
threshold. linkage decides when a span joins a group that has more members. Each
group gets a central span: the member closest to all the others. Every span is in a
group, so there is no noise. The function loads the embedding model.
Arguments
items
The spans to work on.
- Values: the relations from causenet() (the function reads “cause” and “effect”), or a plain list of span strings. Each unique span is used once.
- Default: none. Give the relations or the spans.
embeddings
The model that turns a text into a vector.
- Values: “allmpnet” (sentence-transformers/all-mpnet-base-v2), “bge_base” (BAAI/bge-base-en-v1.5), “minilm” (sentence-transformers/all-MiniLM-L6-v2, small and fast), or the name of another sentence-transformers model on Hugging Face.
- Default
"allmpnet": the model of Paper 3. The default thresholds were calibrated for it.
threshold
The cosine that a span needs to join a group.
- Values: a number from -1 to 1 (in practice 0.5 to 0.9). A higher value gives smaller, stricter groups.
- Default
0.69: the value of Paper 3, calibrated for “allmpnet” with “single” linkage.
linkage
How a span is compared with a group.
- Values: “average”: the mean cosine to all the members reaches
threshold. “complete”: the cosine to every member reachesthreshold. “single”: the cosine to one member reachesthreshold. This can chain unrelated spans into one large group (Paper 3’s method). - Default “average” (since 0.2.0).
Returns
{span: {“group”: id, “central”: span}}: for each span, the number of its group (0, 1, 2, …) and the central span of that group.
Raises
ValueError: An unknown linkage.
Example
>>> pairwise_grouping(["social support", "strong social support", "teacher support"])
{'social support': {'group': 0, 'central': 'social support'}, 'strong social support': {'group': 0, 'central': 'social support'}, 'teacher support': {'group': 1, 'central': 'teacher support'}}
clusterer()
Group the spans by density, with HDBSCAN.
clusterer(
items: list,
embeddings: str = 'allmpnet',
min_cluster_size: int = 2,
min_samples: int = 2,
umap: dict | None = None,
) -> dict
Source: theoryminer/harmonizer/cluster.py
The function turns each span into a vector and finds the dense regions: spans with many close neighbours. Each dense region is a group. A span in no dense region is noise (group -1); it stays in the result as its own central span. The function loads the embedding model.
Arguments
items
The spans to work on.
- Values: the relations from causenet() (the function reads “cause” and “effect”), or a plain list of span strings. Each unique span is used once.
- Default: none. Give the relations or the spans.
embeddings
The model that turns a text into a vector.
- Values: “allmpnet” (sentence-transformers/all-mpnet-base-v2), “bge_base” (BAAI/bge-base-en-v1.5), “minilm” (sentence-transformers/all-MiniLM-L6-v2, small and fast), or the name of another sentence-transformers model on Hugging Face.
- Default
"allmpnet": the model of Paper 3. The default thresholds were calibrated for it.
min_cluster_size
The smallest group.
- Values: a whole number, 2 or more.
- Default
2: a pair of spans can be a group (the value of Paper 3).
min_samples
How many close neighbours a span needs to be in a dense region.
- Values: a whole number, 1 or more. A higher value gives fewer groups and more noise.
- Default 2 (the value of Paper 3).
umap
Whether to make the vectors shorter with UMAP before the clustering.
- Values: None, or a dict of UMAP settings, for example dict(n_components=5, n_neighbors=15, min_dist=0.0). The metric is cosine and random_state is 42, unless the dict sets them.
- Default
None: cluster the full vectors.
Returns
{span: {“group”: id, “central”: span}}: for each span, the number of its group (0, 1, 2, …) or -1 for noise, and the central span of that group. A noise span is its own central span.
Raises
ValueError: min_cluster_size below 2 or min_samples below 1 (HDBSCAN checks them).
Example
>>> clusters = clusterer(cards)
>>> clusters["loneliness"], clusters["teacher support"]
({'group': 1, 'central': 'reducing loneliness'}, {'group': -1, 'central': 'teacher support'})
label_groups()
Give each group of spans a name. No thesaurus, no model.
label_groups(
groups: dict,
by: str = 'keywords',
top: int = 3,
) -> dict
Source: theoryminer/harmonizer/labels.py
The keywords of a group are the words that its members repeat and that the other groups do not use (class-based TF-IDF):
score(word) = (count in the group / words in the group)
* log(1 + groups / groups that hold the word)
Function words (“the”, “of”, …) never enter a name. A group with one member takes the member as its name. The spans are not changed. The function reads no file.
Arguments
groups
The groups to name.
- Values: the result of pairwise_grouping() or clusterer(): {span: {“group”: id, “central”: span}}.
- Default: none. Give the groups.
by
How the name is made.
- Values: “keywords”: the
topkeywords, joined by a space. “medoid”: the central span. “both”: the central span, “ | “, then the keywords. - Default “keywords” (since 0.2.0).
top
The number of keywords for each group.
- Values: a whole number, 1 or more.
- Default 3.
Returns
{key: group}: one dict per group. The key is the group id, or the span text for a noise span. Each group has the keys
| key | meaning |
|---|---|
central | the central span of the group |
size | the number of members |
members | the spans of the group |
keywords | up to top (word, score) pairs, best first |
name | the name, made by by |
Raises
ValueError: An unknown by.
Example
>>> labelled = label_groups(clusterer(cards))
>>> labelled[1]["size"], labelled[1]["name"]
(5, 'loneliness reducing alleviate')
standardize_constructs()
Find the closest thesaurus concepts for each span.
standardize_constructs(
items: list,
taxonomy: str = 'elsst',
strategy: str = 'enriched',
top: int = 5,
embeddings: str = 'allmpnet',
leaves_only: bool = False,
cache: bool = True,
threshold: float | None = None,
) -> dict
Source: theoryminer/harmonizer/standardize.py
The function turns each span (tidied and lowercased, no word removed) and each thesaurus
concept into a vector, and scores each pair with the cosine. It returns the top best
concepts of each span, one per concept, best first. The first call makes the thesaurus
vectors and saves them (see cache). The function loads the embedding model.
Arguments
items
The spans to work on.
- Values: the relations from causenet() (the function reads “cause” and “effect”), or a plain list of span strings. Each unique span is used once.
- Default: none. Give the relations or the spans.
taxonomy
The thesaurus.
- Values: “elsst”, or the path of a SKOS .rdf or .rdf.gz file with the ELSST layout: rdf:Description blocks of type skos:Concept, with an English skos:prefLabel.
- Default
"elsst": ELSST release 5 (3,436 concepts), which ships with the package.
strategy
The text that stands for each thesaurus concept.
- Values: “leaf” (the concept name), “path” (the path from the top of the thesaurus), “anchor” (the name, then the path), “context” (“<name> is related to <parents>”), “bracket” (the name, with the parents in brackets), “enriched” (the name, the other names, the definition and the path).
- Default “enriched”.
top
The number of concepts to return, best first.
- Values: a whole number, 1 or more.
- Default 5.
embeddings
The model that turns a text into a vector.
- Values: “allmpnet”, “bge_base”, “minilm”, or the name of another sentence-transformers model on Hugging Face. A new model makes new thesaurus vectors the first time.
- Default “allmpnet”.
leaves_only
Which thesaurus concepts can be a match.
- Values: True: only the most specific concepts (the leaves). False: all concepts, the broad ones too, for example TRUST and MOTIVATION.
- Default
False: all concepts (the default since 0.2.0).
cache
Whether to keep the thesaurus vectors on disk.
- Values: True or False.
- Default
True: read the vectors from ~/.cache/theoryminer/taxonomy (or from the folder in THEORYMINER_CACHE_DIR), and make and save them on the first call (about 3 minutes on a CPU). False makes them again in each session.
threshold
The lowest score that a concept needs to be a match. The score is a cosine.
- Values: None, or a number from -1 to 1 (in practice 0.4 to 0.7).
- Default
None: keep thetopconcepts, however low their score.
Returns
{span: [match, …]}: for each span, a list of up to top dicts, best first, with the keys
| key | meaning |
|---|---|
id | the web address of the concept |
leaf | the name of the concept |
path | the path from the top of the thesaurus to the concept |
score | the cosine, rounded to 4 decimals |
The list is empty when no concept reaches threshold.
Raises
ValueError: An unknown taxonomy (not “elsst” and not a file), or an unknown strategy.
Example
>>> matches = standardize_constructs(["loneliness", "social trust"], top=2)
>>> [(m["leaf"], m["score"]) for m in matches["social trust"]]
[('TRUST', 0.7604), ('INTERPERSONAL TRUST', 0.6876)]
standardize_groups()
Find the closest thesaurus concepts for each group of spans.
standardize_groups(
groups: dict,
name_by: str = 'mean_sim',
taxonomy: str = 'elsst',
strategy: str = 'enriched',
top: int = 5,
embeddings: str = 'allmpnet',
leaves_only: bool = False,
cache: bool = True,
threshold: float | None = None,
) -> dict
Source: theoryminer/harmonizer/standardize.py
The function scores each group against each thesaurus concept, with the rule in name_by,
and returns the top best concepts of each group. A noise span (group -1) is a group of
its own. The first call makes the thesaurus vectors and saves them (see cache). The
function loads the embedding model.
Arguments
groups
The groups to name.
- Values: the result of pairwise_grouping() or clusterer(): {span: {“group”: id, “central”: span}}.
- Default: none. Give the groups.
name_by
How a group gets one score for each concept.
- Values: “mean_sim”: score each member, then take the mean. A mixed group scores low. “centroid”: take the mean vector of the members, then score it. It always ranks the concepts as “mean_sim” does, with higher scores. “medoid”: score the central span only.
- Default “mean_sim”.
taxonomy
The thesaurus.
- Values: “elsst”, or the path of a SKOS .rdf or .rdf.gz file with the ELSST layout: rdf:Description blocks of type skos:Concept, with an English skos:prefLabel.
- Default
"elsst": ELSST release 5 (3,436 concepts), which ships with the package.
strategy
The text that stands for each thesaurus concept.
- Values: “leaf” (the concept name), “path” (the path from the top of the thesaurus), “anchor” (the name, then the path), “context” (“<name> is related to <parents>”), “bracket” (the name, with the parents in brackets), “enriched” (the name, the other names, the definition and the path).
- Default “enriched”.
top
The number of concepts to return, best first.
- Values: a whole number, 1 or more.
- Default 5.
embeddings
The model that turns a text into a vector.
- Values: “allmpnet”, “bge_base”, “minilm”, or the name of another sentence-transformers model on Hugging Face. A new model makes new thesaurus vectors the first time.
- Default “allmpnet”.
leaves_only
Which thesaurus concepts can be a match.
- Values: True: only the most specific concepts (the leaves). False: all concepts, the broad ones too, for example TRUST and MOTIVATION.
- Default
False: all concepts (the default since 0.2.0).
cache
Whether to keep the thesaurus vectors on disk.
- Values: True or False.
- Default
True: read the vectors from ~/.cache/theoryminer/taxonomy (or from the folder in THEORYMINER_CACHE_DIR), and make and save them on the first call (about 3 minutes on a CPU). False makes them again in each session.
threshold
The lowest score that a concept needs to be a match. The score is a cosine.
- Values: None, or a number from -1 to 1 (in practice 0.4 to 0.7).
- Default
None: keep thetopconcepts, however low their score.
Returns
{key: group}: one dict per group. The key is the group id, or the span text for a noise span. Each group has the keys
| key | meaning |
|---|---|
central | the central span of the group |
size | the number of members |
members | the spans of the group |
matches | up to top concepts, best first, with the keys id, leaf, path and score; empty when no concept reaches threshold |
Raises
ValueError: An unknown name_by, an unknown taxonomy, or an unknown strategy.
Example
>>> named = standardize_groups(clusterer(cards), top=1)
>>> named[1]["size"], named[1]["matches"][0]["leaf"]
(5, 'LONELINESS')
causal_map()
Build the causal map from the relations and the names of their spans.
causal_map(
relations: list[dict],
names: dict,
min_papers: int = 1,
min_sentences: int = 2,
min_share: float = MIN_SHARE,
) -> dict
Source: theoryminer/causal_map.py
Each node is a construct: a named group, or a thesaurus concept. Each edge is “cause node -> effect node”, with the number of papers, sentences and relations that claim it. Self-loops and two-way pairs get flags. A filter hides a weak edge, but deletes nothing: a hidden edge goes to “hidden” with its drop_reason. The function reads no file and loads no model.
Arguments
relations
The relations from causenet().
- Values: a list of relation dicts. Each relation needs rel_id, doc_id, cause and effect; sent_id is used when it is there.
- Default: none. Give the relations.
names
The names of the spans, made from the same relations.
- Values: the result of label_groups() (a node is a group, named by its name), standardize_groups() (a node is the best concept of a group; groups with the same best concept share a node), or standardize_constructs() (a node is the best concept of a span). A group or span without a concept keeps its own node, under its own text.
- Default: none. Give the names.
min_papers
Show an edge only when at least this many papers claim it.
- Values: a whole number, 1 or more.
- Default
1: show the edge whatever the number of papers. This also suits one long book.
min_sentences
Show an edge only when at least this many different sentences claim it.
- Values: a whole number, 1 or more.
- Default
2: hide the claims that only one sentence makes. Use 1 to show every edge.
min_share
The share of the papers that one side of a two-way pair (A -> B and B -> A) needs to be the main direction.
- Values: a number above 0.5, up to 1.
- Default
0.67: one side needs 2/3 of the papers. Then it is “dominant” and the other side is “minority”. Else both sides are “contested”.
Returns
A dict with the keys
| key | meaning |
|---|---|
nodes | {node_id: node}: label, members, as_cause, as_effect, n_sentences, n_papers, role (“cause only”, “effect only” or “cause and effect”) and shown |
edges | the shown edges: edge_id, source, target, n_papers, n_sentences, n_relations, rel_ids, doc_ids, direction (“one-way”, “dominant”, “minority” or “contested”), reciprocal, self_loop and drop_reason (“”) |
hidden | the hidden edges, with drop_reason “below_min_papers” or “below_min_sentences” |
relations | {rel_id: relation} |
papers | one dict per paper, {“doc_id”, “doi”, “title”}, from the relations; draw_map() shows them |
settings | {“min_papers”, “min_sentences”, “min_share”} |
Raises
ValueError: The relations are empty; names is empty or has an unknown shape; a span of a relation is not in names; or a setting is out of its range.
Example
>>> relations = [
... {"rel_id": "r1", "sent_id": "s1", "doc_id": "p1", "cause": "job insecurity", "effect": "stress"},
... {"rel_id": "r2", "sent_id": "s2", "doc_id": "p2", "cause": "fear of job loss", "effect": "anxiety"}]
>>> names = {0: {"central": "job insecurity", "members": ["job insecurity", "fear of job loss"],
... "name": "job insecurity"},
... 1: {"central": "stress", "members": ["stress", "anxiety"], "name": "stress"}}
>>> cmap = causal_map(relations, names)
causal map: 2 nodes | 1 edges shown, 0 hidden | self-loops: 0 edges, 0 of 2 relations (0.0%) | two-way pairs: 0 (0 contested)
>>> edge = cmap["edges"][0]
>>> edge["source"], edge["target"], edge["n_papers"], edge["rel_ids"]
('g:0', 'g:1', 2, ['r1', 'r2'])
draw_map()
Draw the causal map as an interactive page.
draw_map(
cmap: dict,
papers: list[dict] | None = None,
save: str | None = None,
title: str = 'Causal map',
height: int = FRAME_HEIGHT,
) -> None
Source: theoryminer/causal_map.py
In a notebook, the page shows in the cell output. In a plain Python script, the function writes the page to causal_map.html. The page is one HTML file that works offline. On the page you can hover over a node or an edge, click an edge to read all its sentences, search, drag, zoom, change the filters, and save a picture (PNG, JPG or SVG). The page gets every edge, also the hidden edges, and its sliders start at the filter values of causal_map().
Arguments
cmap
The map to draw.
- Values: the result of causal_map().
- Default: none. Give the map.
papers
The source of each paper, shown with each sentence: the DOI as a link, else the paper title, else the file name.
- Values: None, or the rows of extract_dois() (they replace the papers of the map).
- Default
None: use cmap[“papers”], the DOI and title that harvest() found for each paper. A map from relations without them shows the doc_id (the file name) of each paper.
save
Where to write the page as an HTML file, in addition to the notebook output.
- Values: None, or a path such as “outputs/map.html”.
- Default
None: write no file in a notebook, and causal_map.html in a script.
title
The title at the top of the page.
- Values: any text.
- Default “Causal map”.
height
The height of the map in a notebook cell.
- Values: a whole number of pixels.
- Default 720.
Returns
None. The function shows the page, or writes the file, or both.
Example
>>> draw_map(cmap)
>>> draw_map(cmap, save="outputs/map.html")
saved page: outputs/map.html
save_map()
Write the causal map to a folder: three CSV tables and one GraphML file.
save_map(
cmap: dict,
folder: str,
) -> list[str]
Source: theoryminer/causal_map.py
The files: nodes.csv one row per node, also the nodes that no shown edge touches (shown False) edges.csv one row per shown edge hidden.csv one row per hidden edge, with its drop_reason map.graphml every node and edge, for Gephi or Cytoscape desktop In the CSV files, a list (members, doc_ids, rel_ids) is one text, joined by “ | “. The function overwrites old files of the same name.
Arguments
cmap
The map to save.
- Values: the result of causal_map().
- Default: none. Give the map.
folder
The folder for the four files.
- Values: the path of a folder. A missing folder is made.
- Default: none. Give a folder.
Returns
A list of the paths of the four files.
Example
>>> save_map(cmap, "outputs/my_map")
saved map: outputs/my_map (nodes.csv, edges.csv, hidden.csv, map.graphml)
Settings and defaults
Each setting of each function, its default, and where the default comes from. The reference pages give every permitted value.
| function | setting | default | source of the default |
|---|---|---|---|
harvest | min_words / max_words | 4 / 80 | old cleaning notebook |
extract_dois | first_pages / save | 2 / None | the paper’s own DOI is on page 1 (checked by hand: 7 of 7) |
extract_references | save / cache | None / True | 214 of 215 DOIs of the sample papers exist at doi.org |
download_papers | licences | None | ["cc-by"] keeps only CC BY papers (see A snowball round) |
download_papers | sources | None | None = the nine free sources, from openalex to landing_page; add tmsr-doi-downloader |
download_papers | respect_robots | True | obey robots.txt; set False only with the right to do so |
download_papers | email | None | the polite pool of OpenAlex and Crossref, and Unpaywall; None keeps UNPAYWALL_EMAIL |
causenet | threshold | 0.8 | old pipeline sweep 0.5–0.8 |
causenet | mode | neural | model card default |
causenet | decision | span_only | since 0.2.0 (cls+span before): a cause span and an effect span are enough |
causenet | avoid_ambiguous | True | since 0.2.0: skip a relation with a pointer span (“this”, “it”) |
causenet | batch_size / device | 64 / auto | auto = an NVIDIA GPU, else an Apple GPU, else the CPU |
| all harmonizer | embeddings | allmpnet | Paper 3; bge_base scores 0.02 F1 higher (Thesaurus strategies, section 5) |
standardize_* | taxonomy | elsst | project choice; the only built-in thesaurus |
standardize_* | strategy | enriched | Paper 3 web tool; confirmed by the held-out test |
standardize_* | top | 5 | project choice |
standardize_* | leaves_only | False | since 0.2.0 (True before): broad concepts too, such as TRUST and MOTIVATION |
label_groups | by / top | keywords / 3 | since 0.2.0 (both before): the TF-IDF keywords of the group |
standardize_* | threshold | None | since 0.2.0; None gives every span its best concept |
standardize_groups | name_by | mean_sim | old pipeline’s cluster-naming comparison |
pairwise_grouping | threshold | 0.69 | Paper 3 BEST_PAIRWISE_THRESHOLDS |
pairwise_grouping | linkage | average | since 0.2.0 (single before): single linkage chains (see Group and name constructs) |
clusterer | min_cluster_size / min_samples | 2 / 2 | Paper 3 BEST_CLUSTERING_PARAMS |
clusterer | umap | None | Paper 3; the best run had no reduction |
causal_map | min_papers / min_sentences | 1 / 2 | project choice; 1 paper works for one long book |
causal_map | min_share | 0.67 | project choice: a two-way pair needs 2 papers to 1 for one direction |
draw_map | save / height | None / 720 | a notebook shows the page; a script writes causal_map.html |
τ, the score that a span needs for a thesaurus concept, is the threshold of the standardize_*
functions. It is off by default. Choose it per model, per strategy, per thesaurus.
The evidence behind the defaults is in Thesaurus strategies, section 5.
Data shapes
The functions pass plain Python lists and dicts. This page lists the keys of each shape, so that you can read the results in your own code or in pandas.
A sentence
One item of harvest(...)["sentences"].
| Key | Meaning |
|---|---|
doc_id | the file name without .pdf |
page | the page number |
sent_id | a unique id, for example d000p0012s003 |
section | the last heading before the sentence |
raw | the sentence as the sentence splitter gave it |
clean | the sentence that causenet() reads |
flag | "", "front_matter", "fragment" or "long" |
doi, title | the paper, or "" |
A dropped row
One item of harvest(...)["dropped"]: doc_id, page, section, drop_reason and text.
A relation
One item of the list that causenet() returns.
| Key | Meaning |
|---|---|
rel_id | a unique id, for example d000p0012s003r01 |
sent_id, doc_id, page | from the sentence |
cause, effect | the two spans, in lowercase |
sentence | the clean sentence |
doi, title | the paper, or "" |
Groups
The result of clusterer() and pairwise_grouping(), keyed by the span:
{"loneliness": {"group": 4, "central": "address loneliness"},
"levels of loneliness": {"group": 4, "central": "address loneliness"}}
A noise span of clusterer() has the group -1.
Names
The result of label_groups(), keyed by the group id (or by the span, for a noise span):
{4: {"central": "address loneliness", "size": 6,
"members": ["loneliness", "levels of loneliness", ...],
"keywords": [("loneliness", 0.91), ...],
"name": "loneliness levels address"}}
standardize_groups() and standardize_constructs() give each group or span a list of
thesaurus matches, best first. See their reference pages.
The causal map
causal_map() returns a dict with six keys:
| Key | What it holds |
|---|---|
nodes | {node_id: node} |
edges | the list of shown edges |
hidden | the list of hidden edges, each with its drop_reason |
relations | {rel_id: relation}, the relations behind the edges |
papers | one row per paper: doc_id, doi, title |
settings | min_papers, min_sentences, min_share |
A node:
| Key | Meaning |
|---|---|
node_id, label | the id and the name of the node |
members | the spans in the node |
as_cause, as_effect | the sentences of the shown edges that start at the node, and that end at it |
n_sentences | as_cause plus as_effect |
n_papers | the papers of the shown edges that touch the node |
role | cause only, effect only or cause and effect; "" when no shown edge touches the node |
shown | True when a shown edge touches the node |
The counts use the shown edges only. A filter that hides an edge also lowers the counts of its nodes.
An edge: edge_id, source, target, n_papers, n_sentences, n_relations, rel_ids,
doc_ids, direction, reciprocal, self_loop and drop_reason. See
The causal map for their meaning.
Standardization strategies, with leaves_only on and off
Companion to TUTORIAL.ipynb section 4. Every text and score below is real: it was
printed by strategy_text and standardize_constructs on ELSST R5 with allmpnet.
1. What a strategy is
standardize_constructs matches a span to a taxonomy concept by embedding both and taking the
cosine. The span is embedded as it is. The concept has no natural text, so a strategy is
the recipe that builds one. Six recipes exist. They use four fields of a taxonomy entry:
| field | meaning | SELF-ESTEEM |
|---|---|---|
leaf | the concept’s own label | SELF-ESTEEM |
path | the chain of broader concepts from a top concept down to it | IDENTITY > PERSONAL IDENTITY > SELF-ESTEEM |
parents | the broader concepts, nearest first | PERSONAL IDENTITY, IDENTITY |
alt | the entry terms (synonyms) | SELF-ACCEPTANCE, SELF-CONFIDENCE, SELF-RESPECT |
note | the English scope note or definition, often empty | (empty) |
Everything is lowercased before embedding.
| strategy | recipe | SELF-ESTEEM |
|---|---|---|
leaf | label | self-esteem |
path | path | identity > personal identity > self-esteem |
anchor | label: path | self-esteem: identity > personal identity > self-esteem |
context | label is related to parents | self-esteem is related to personal identity, identity |
bracket | label (parents) | self-esteem (personal identity, identity) |
enriched | label. synonyms. note. path | self-esteem. self-acceptance; self-confidence; self-respect. identity > personal identity > self-esteem |
Each strategy is one answer to one question: how much of the tree should the concept vector
carry? leaf carries none. path, anchor, context, bracket carry the ancestors in four
phrasings. enriched carries the ancestors and, more important, the synonyms.
2. What leaves_only is
ELSST is a tree. A leaf has no narrower concept under it. A branch has children.
MOTIVATION branch (and a top concept: no parent)
├── ASPIRATION leaf
└── INCENTIVES leaf
WELL-BEING (HEALTH) branch (top concept)
├── PSYCHOLOGICAL WELL-BEING leaf
├── LIFESTYLE AND HEALTH leaf
├── AGEING leaf
└── ENVIRONMENTAL EXPOSURE leaf
IDENTITY > PERSONAL IDENTITY > SELF-ESTEEM leaf, two paths (also under IMAGE)
leaves_only=True (default) | leaves_only=False | |
|---|---|---|
| who can be an answer | the 2,417 leaves | all 3,436 concepts |
| entries embedded | 2,661 | 3,739 |
| cache file | elsst_<strategy>_<model>_leaves.npy | elsst_<strategy>_<model>_all.npy |
| where a span lands | the bottom of the tree, always | any level |
The switch changes which concepts may answer. It does not change any recipe or any score. A leaf’s text is the same under both settings.
How a branch becomes an entry
With leaves_only=False every branch gets an entry of the same shape as a leaf entry: its
own label as leaf, the path from a top concept down to itself, its own parents. The six
recipes then apply unchanged. Two cases:
A branch with parents (PERSONAL IDENTITY, child of IDENTITY and of IMAGE):
| strategy | text |
|---|---|
leaf | personal identity |
path | identity > personal identity |
anchor | personal identity: identity > personal identity |
context | personal identity is related to identity |
bracket | personal identity (identity) |
enriched | personal identity. <synonyms>. identity > personal identity |
A top concept (no parent; 247 of them in R5, MOTIVATION and WELL-BEING (HEALTH) among them). The path is the label alone, the parents list is empty, and the recipes collapse:
| strategy | MOTIVATION |
|---|---|
leaf | motivation |
path | motivation |
anchor | motivation: motivation |
context | motivation |
bracket | motivation |
enriched | motivation. motivation |
anchor and enriched repeat the label for a top concept because the path is the label.
Harmless for the cosine, but not pretty. It is the one place where a recipe would deserve a
special case (skip the path when it equals the label).
A leaf with two paths
SELF-ESTEEM sits under IDENTITY and under IMAGE. It gets two entries, one per path, under both settings. The label, synonyms and note are the same in both; the path and parents differ:
| strategy | entry 1 | entry 2 |
|---|---|---|
leaf | self-esteem | self-esteem (identical vector) |
path | identity > personal identity > self-esteem | image > personal identity > self-esteem |
bracket | self-esteem (personal identity, identity) | self-esteem (personal identity, image) |
At lookup the span is scored against both and the best score is kept for the concept. No
path is preferred by depth or by any other rule; the span picks the path that fits it. 244 of
the 2,661 leaf entries come from this (97 of those concepts also have synonyms). Under leaf
the two vectors are identical and the duplicate falls away in the de-duplication.
3. The same spans under every strategy, on and off
Six SDT spans, top-1 concept and cosine, allmpnet.
leaves_only=True
| span | leaf | path | anchor | context | bracket | enriched |
|---|---|---|---|---|---|---|
| intrinsic motivation | INCENTIVES 0.56 | INCENTIVES 0.72 | INCENTIVES 0.62 | INCENTIVES 0.62 | INCENTIVES 0.70 | INCENTIVES 0.65 |
| well-being | MENTAL HEALTH 0.70 | HAPPINESS 0.66 | HAPPINESS 0.63 | HAPPINESS 0.55 | HAPPINESS 0.70 | HAPPINESS 0.55 |
| autonomy support | AUTONOMY AT WORK 0.72 | AUTONOMY AT WORK 0.59 | AUTONOMY AT WORK 0.67 | AUTONOMY AT WORK 0.65 | AUTONOMY AT WORK 0.65 | AUTONOMY AT WORK 0.57 |
| self-confidence | SELF-ESTEEM 0.79 | SELF-ESTEEM 0.57 | SELF-ESTEEM 0.60 | SELF-ESTEEM 0.56 | SELF-ESTEEM 0.63 | SELF-ESTEEM 0.58 |
| need frustration | ANGER 0.55 | SATISFACTION 0.54 | BOREDOM 0.50 | SATISFACTION 0.54 | BOREDOM 0.56 | SATISFACTION 0.50 |
| academic achievement | ACADEMIC ACHIEVEMENT 1.00 | ACADEMIC ACHIEVEMENT 0.83 | ACADEMIC ACHIEVEMENT 0.82 | ACADEMIC ACHIEVEMENT 0.80 | ACADEMIC ACHIEVEMENT 0.90 | ACADEMIC ACHIEVEMENT 0.69 |
leaves_only=False
| span | leaf | path | anchor | context | bracket | enriched |
|---|---|---|---|---|---|---|
| intrinsic motivation | MOTIVATION 0.77 | MOTIVATION 0.77 | MOTIVATION 0.73 | MOTIVATION 0.77 | MOTIVATION 0.77 | MOTIVATION 0.69 |
| well-being | WELL-BEING (HEALTH) 0.82 | WELL-BEING (HEALTH) 0.82 | WELL-BEING (SOCIETY) 0.70 | WELL-BEING (HEALTH) 0.82 | WELL-BEING (HEALTH) 0.82 | WELL-BEING (HEALTH) 0.61 |
| autonomy support | AUTONOMY AT WORK 0.72 | AUTONOMY AT WORK 0.59 | AUTONOMY AT WORK 0.67 | AUTONOMY AT WORK 0.65 | AUTONOMY AT WORK 0.65 | AUTONOMY AT WORK 0.57 |
| self-confidence | SELF-ESTEEM 0.79 | SELF-ESTEEM 0.57 | SELF-ESTEEM 0.60 | SELF-ESTEEM 0.56 | SELF-ESTEEM 0.63 | SELF-ESTEEM 0.58 |
| need frustration | ANGER 0.55 | SATISFACTION 0.54 | BOREDOM 0.50 | SATISFACTION 0.54 | BOREDOM 0.56 | SATISFACTION 0.50 |
| academic achievement | ACADEMIC ACHIEVEMENT 1.00 | ACADEMIC ACHIEVEMENT 0.83 | ACADEMIC ACHIEVEMENT 0.82 | ACADEMIC ACHIEVEMENT 0.80 | ACADEMIC ACHIEVEMENT 0.90 | ACADEMIC ACHIEVEMENT 0.69 |
How to read the two tables:
- Rows 3 to 6 are identical on and off. When the right concept is already a leaf, the switch does nothing. The only change in the candidate list is the addition of branches, and none of them beats the leaf for these spans.
- Rows 1 and 2 change under every strategy. intrinsic motivation moves from
INCENTIVES (a child, and the wrong child: incentives are external rewards) to MOTIVATION.
well-being moves from HAPPINESS or MENTAL HEALTH to WELL-BEING (HEALTH). Both targets are
top concepts that leaves-only cannot offer. The scores rise too (0.65 → 0.69, 0.55 → 0.61
under
enriched), which says the branch is a closer match, not only a permitted one. - The strategy decides the score, not the concept. Down a column the winner rarely
changes; the cosine does.
leafgives the highest cosines for exact and near-exact labels (academic achievement1.00,self-confidence0.79 through the synonym in the label space).enrichedgives the lowest, because its long text spreads the vector over synonyms and ancestors. This matters for τ: a threshold tuned forleafis too strict forenriched. need frustrationfails everywhere. ANGER, BOREDOM, SATISFACTION at 0.50–0.56. ELSST has no concept for it. No strategy and no switch invents one; this is the standardisation-failure class.- One warning in the
anchorcolumn. well-being → WELL-BEING (SOCIETY) instead of WELL-BEING (HEALTH). The two top concepts have the same name apart from the bracket; theanchorrecipe repeats a top concept’s label twice, which shifts its vector enough to flip the tie. A recipe artefact, not a signal.
4. Which strategy, with the switch on or off
From the entry-term test in section 5 (positive and negative pairs, three models, both settings, plus a held-out synonym test):
- The ranking of strategies is the same on and off:
leaf≥enriched>anchor>context≈bracket>path. Turning the switch off moves every F1 by at most 0.01. enrichedstays the default. On held-out synonyms it is never worse thanleafbeyond noise and has the best hit@5; on real spans, which are paraphrases rather than labels, the synonyms are the part that helps.pathis the weakest under both settings. Ancestors written as a chain dilute the label.- The switch is a granularity choice, not an accuracy one: off, broad constructs get their own concept and the graph carries nodes at mixed depth; on, every node is a leaf and broad constructs are pushed onto a child.
5. The entry-term test: how the defaults were chosen
A thesaurus lists its own synonyms. ELSST gives them as skos:altLabel. Each synonym with
its concept is a positive pair: self-confidence ↔ SELF-ESTEEM. For each positive pair
the test adds two negative pairs with the same query: a sibling concept (same nearest
parent; hard) and a random concept (easy). A fixed seed draws the negatives once. A
setting scores a pair by the cosine between the query and the concept text. The rule is same
concept if score ≥ τ. A sweep over τ gives the best F1 and its τ. AUC is the threshold-free
view. Top-1 accuracy asks if the gold concept wins the ranking against the whole thesaurus.
ELSST, three scopes:
| scope | candidates | queries | pairs | reads as |
|---|---|---|---|---|
| leaves | 2,417 leaves | synonyms of leaves | 5,532 | leaves_only=True |
| all | 3,436 concepts | synonyms of all concepts | 7,888 | leaves_only=False |
| all·leafq | 3,436 concepts | synonyms of leaves | 5,584 | the cost of the extra candidates on the same queries |
enriched puts the synonyms inside the concept text. On this test the query sits inside its
own target, and the score is inflated (F1 0.84–0.92). enriched_noalt (label, note, path; no
synonyms) is the fair version. Best F1, fair rows, scope leaves / all:
| strategy | allmpnet | bge_base | minilm |
|---|---|---|---|
leaf | 0.79 / 0.80 | 0.82 / 0.82 | 0.78 / 0.79 |
enriched_noalt | 0.78 / 0.78 | 0.79 / 0.79 | 0.76 / 0.76 |
anchor | 0.77 / 0.78 | 0.79 / 0.79 | 0.75 / 0.76 |
context | 0.77 / 0.78 | 0.77 / 0.77 | 0.75 / 0.76 |
bracket | 0.76 / 0.77 | 0.79 / 0.79 | 0.74 / 0.75 |
path | 0.75 / 0.75 | 0.79 / 0.79 | 0.73 / 0.73 |
- Strategy. The bare label wins. Each phrasing of the tree and the note pull the vector away from the label and cost 0.02–0.05 F1. The spread between strategies is small next to the spread between models.
- Model.
bge_basebeatsallmpnetby about 0.02 F1 and 0.04 top-1 accuracy in every cell.minilmis last.bge_baseseparates positives from siblings more sharply: τ* is 0.65–0.70, against 0.45–0.55 forallmpnet. leaves_only. The branches as candidates cost F1 0.005 and top-1 accuracy 0.03–0.07 on the same queries. The branches bring their own labels and synonyms in return. The switch is a granularity choice, not an accuracy choice.- τ. τ* depends on the model: about 0.50 for
allmpnetwithleaf, about 0.70 forbge_base. The old τ = 0.55 was set forall-mpnet-base-v2. Do not reuse it for another model, another strategy or another thesaurus. - The hard errors are the thesaurus’s own granularity. Siblings accepted at τ*: sporting activities → SPORTS CLUBS (gold SPORTS); distance courses → ONLINE EDUCATION (gold DISTANCE EDUCATION); achievement tests → INTELLIGENCE TESTS (gold EDUCATIONAL TESTS). Positives missed: mores → CUSTOMS AND TRADITIONS (0.45); perks → FRINGE BENEFITS (0.63). No setting resolves these. They are the granularity-mismatch class of the disagreement classifier.
Do the synonyms in enriched help with a synonym the text has never seen? A second test
holds one out. For the 651 ELSST concepts with two or more synonyms, one synonym leaves the
concept text and becomes the query. Mean F1 over three models and two scopes:
| concept text | F1 | hit@5 |
|---|---|---|
| label + remaining synonyms | 0.811 | 0.806 |
enriched: label + synonyms + note + path | 0.806 | 0.821 |
leaf | 0.799 | 0.796 |
| label + note + path, no synonyms | 0.779 | 0.766 |
The synonyms generalise. They add 0.01–0.03 F1 over the bare label for bge_base and
minilm, and nothing for allmpnet. The note and the path cost about 0.02 alone. The
synonyms pay that cost back. enriched stays the default: it is never worse than leaf
beyond noise, it gives the best hit@5, and it is the recipe Paper 3 ships.
The test itself was a temporary script and is not part of the package.
6. Where to look in the code
- Recipes:
strategy_textintheoryminer/harmonizer/taxonomy.py. - Entries and paths:
_load_elsstand its innerpaths_to_rootin the same file. All root-to-concept paths are kept, one entry each; nothing is chosen by depth. - The switch:
load_taxonomy(name, leaves_only); the cache key includes it. - Lookup and de-duplication by concept:
_top_matchesinstandardize.py.
Tutorials
The repository has two notebooks. Both use the 7 sample papers in sample_files/.
| Notebook | What it does | Open it |
|---|---|---|
TUTORIAL.ipynb | runs every function and every setting, with real output. Section 10 is a full run on the sample papers, up to the causal map. Section 11 is one snowball round. | GitHub · Colab |
PACKAGE_LINE_BY_LINE.ipynb | takes each function apart: it shows the code, runs its lines by hand, and checks the result against the real function | GitHub · Colab |
In Colab, the first cell installs the package and downloads the sample papers. Choose a GPU
runtime for causenet(): Runtime → Change runtime type → T4 GPU.
On your own computer, run the notebooks from tutorials/ or from the repository root. The first
cell moves to the repository root.
The page Thesaurus strategies explains the six ways to turn a thesaurus concept into text, and the test behind the defaults.
The sample papers
sample_files/ holds 7 open-access social-science papers with a CC BY licence. The file
sample_files/SOURCES.md gives the title, the authors, the DOI and the licence of each paper.
Changelog
Each release of theoryminer has one section. The newest release is first.
The version numbers follow semantic versioning: before 1.0.0, a minor release (0.x.0) can change the API.
0.3.0
This release rewrites download_papers() and restructures the first stages into folders.
Added
- A documentation website: https://rasoulnorouzi.github.io/theoryminer/. It has a guide for each
step, a reference page for each public function, and every setting with its default. The book is
made with mdBook from
docs/. The reference pages come from the docstrings (docs/make_api.py), so they always match the code. A push tomainpublishes the book with GitHub Pages. download_papers()is rewritten. It tries many free sources in turn until one gives a real PDF: OpenAlex, per-publisher plugins, PubMed Central, Europe PMC, Unpaywall, Semantic Scholar, Crossref, OSF preprints, and the doi.org landing page. On a 100-DOI benchmark it downloads the correct PDF for 33 papers; the old version (OpenAlex, thentmsr-doi-downloader) downloaded 25. It is also faster. The newsourcessetting chooses the sources and their order.- Per-publisher plugins, in the style of Zotero translators (
theoryminer/download/publishers.py). A plugin turns a DOI into the direct PDF link of its publisher (Springer with BMC and Palgrave, and Frontiers). To add a journal, add one plugin. download_papers()gets open-access copies only. It does not log in to a publisher site and it does not use a browser. A paper that is only behind a paywall staysnot_found.respect_robots(defaultTrue). Set itFalseonly when you have the right to download from the sites, for example by an agreement of your institution.- The DOI and title of each paper travel with the data.
harvest()putsdoiandtitleon every sentence (new settingfind_dois, defaultTrue).causenet()copies them to every relation.causal_map()collects them incmap["papers"], anddraw_map(cmap)shows them. The calldraw_map(cmap, papers=extract_dois(folder))is no longer needed;papers=still works. - A better DOI rule, shared by
harvest()andextract_dois(). The DOI comes from the most certain source: the download logdownloads.csvof the folder, a DOI in the text that gives the file name of adownload_papers()PDF, the subject or keywords field of the PDF metadata, then the first DOI on pages 1-2.extract_dois()rows have a new columnsource. On 21 papers with a known DOI (7 sample papers and 14 downloads) the rule finds 20; the old rule found 18. download_papers()always writes its logdownloads.csvinto the PDF folder. The log keeps the rows of earlier runs.harvest()reads it, so it knows the DOI of every downloaded PDF.
Changed
-
The modules of the first stages are in two new folders. The public functions do not change:
from theoryminer import harvest, extract_dois, extract_references, download_papersworks as before.Before Since this release theoryminer/harvest.pytheoryminer/prepare/harvest.pytheoryminer/dois.pytheoryminer/prepare/dois.pytheoryminer/download.pytheoryminer/download/papers.py -
download_papers()no longer usestmsr-doi-downloaderin the default. On the benchmark it added one paper per hundred and doubled the run time.tmsr-doi-downloaderstays a dependency and an opt-in source:sources=[..., "tmsr-doi-downloader"]. -
The docstrings of the public functions give, for each argument, every permitted value and what the default does in words. For example,
harvest(steps=None)names the 10 steps ofDEFAULT_STEPS. Five functions got an example:causenet(),standardize_constructs(),standardize_groups(),pairwise_grouping()andclusterer(). A new test,tests/test_docstrings.py, checks every argument and every choice value. -
causenet()checks its settings before the model loads. An unknownmodeordecision, athresholdoutside 0 to 1, or abatch_sizebelow 1 raises aValueError.modeaccepts"neural"and"auto". Before,mode="heuristic"(or a typing error) ran the neural path without a message, and an unknowndecisionran"cls+span". -
harvest()checks its settings before it reads a PDF. A page selection that is not a page, an unknown step name,min_wordsbelow 1, ormax_wordsbelowmin_wordsraises aValueErrorwith the permitted values. Before, an unknown step gave aKeyError.
Fixed
clusterer()no longer prints aFutureWarningfrom scikit-learn 1.9 about thecopysetting of HDBSCAN. The clusters do not change.tutorials/PACKAGE_LINE_BY_LINE.ipynbruns again without an error. Four cells used the APA thesaurus (removed) or the defaults before 0.2.0 (leaves_only=True,linkage="single").
Deprecated
- The module paths
theoryminer.harvestandtheoryminer.doisstill work, as aliases of the new modules. They go away in 0.4.0. Usetheoryminer.prepare.harvestandtheoryminer.prepare.dois. theoryminer.downloadis now a folder.from theoryminer.download import download_papersstill works. Its private helpers (names that start with_) are intheoryminer.download.papers.
0.2.2
Added
- Editor support. The 13 public functions have type hints: the kind of each setting and of the result.
The package holds the marker file
py.typed(PEP 561), so VS Code (Pylance), PyCharm and type checkers use these hints: they suggest the settings, and they mark a wrong value, for examplemin_words="four". __all__intheoryminerand intheoryminer.harmonizerlists the public names.
0.2.1
Changed
pip install theoryminerinstallstmsr-doi-downloaderon Python 3.11 or newer, sodownload_papers()has all its sources without an extra. On Python 3.10 it is left out, anddownload_papers()uses OpenAlex only. The old commandpip install "theoryminer[download]"still works.- CI installs and tests the package on Windows and macOS with Python 3.11, 3.12 and 3.13, and on Linux with Python 3.10 to 3.13.
Fixed
download_papers()no longer clears a key that you set as an environment variable (UNPAYWALL_EMAIL,CORE_API_KEY,SERPAPI_KEY). A key that you do not pass as an argument keeps its environment value.
0.2.0
Added
- The causal map.
causal_map()turns the relations and the names of their groups into nodes and edges. An edge counts papers, sentences and relations, and keeps itsrel_ids. Two relations of one sentence on one edge count as one sentence. Self-loops and two-way pairs get flags. The filtersmin_papers(default 1) andmin_sentences(default 2) move the weak edges tohidden, with a reason; nothing is deleted. draw_map()shows the map as an interactive page: in a notebook cell, or ascausal_map.htmlfrom a script. The page works offline. It has hover cards, a panel with every sentence and the DOI or title of its paper, a search that filters the map (the matches and their neighbours), filters by paper, role, two-way pairs, papers and sentences, and a picture export as PNG, JPG or SVG.save_map()writesnodes.csv,edges.csv,hidden.csvandmap.graphml.extract_references()finds the DOIs in the references of seed papers, one row per DOI, with the number of seeds that cite it. It repairs a DOI that the PDF cuts at a line end, and it leaves out the seed’s own DOI and its figure and table DOIs. It can save a CSV file with adoicolumn. On the sample papers, 214 of its 215 DOIs exist at doi.org.download_papers()downloads the open-access PDFs of a list of DOIs. It asks OpenAlex for the licence first, solicences=["cc-by"]downloads only CC BY papers. Then it tries the OpenAlex links and, when it is installed,tmsr-doi-downloader(pip install "theoryminer[download]", Python 3.11 or newer). One status row per DOI; a failed DOI does not stop the run; a second run skips the files it has.extract_dois()has a new column,title: the largest font on page 1, else the PDF metadata.causenet(avoid_ambiguous=True)skips a relation whose cause or effect only points to another sentence (“this”, “it”, “such things”). The summary prints the count and examples.pairwise_grouping(linkage=...):"average","complete"or"single".standardize_constructs()andstandardize_groups()have athreshold. It is off by default.harvest()drops the licence and copyright text of a journal, with the reasonlicence_text.
Fixed
causenet()no longer returns a relation with an empty cause or effect (the model decoded only special tokens). The summary counts them. Before,causal_map()stopped on such a relation.
Changed
These defaults change the results of a run that uses them. Give the old value to get the old results.
causenet():decision="span_only"(was"cls+span") andavoid_ambiguous=True(new).pairwise_grouping():linkage="average"(the old method islinkage="single"). Single linkage chains: on the sample papers its largest group held 153 spans; average linkage gives 27.standardize_constructs()andstandardize_groups():leaves_only=False(wasTrue). A span can now also match a broad ELSST concept, such as TRUST or MOTIVATION.label_groups():by="keywords"(was"both"). The label is the TF-IDF keywords of the group.networkxis a declared dependency (save_map()writes GraphML with it). PyTorch installed it already.
Data in the package
causal_map.html(the page template), Cytoscape.js 3.30.2 and the fcose layout (all MIT).
0.1.1
Changed
umap-learnis installed by default.pip install theorymineris enough forclusterer(..., umap={...}). The old commandpip install "theoryminer[umap]"still works.- The thesaurus vectors are saved in
~/.cache/theoryminer/taxonomy, not inside the installed package. The environment variableTHEORYMINER_CACHE_DIRchooses another folder.
Fixed
- A read-only install no longer fails on the first
standardize_constructs()call. When the cache folder cannot be written, the function prints one line and goes on without the cache. pip uninstall theoryminerno longer leaves a cache folder in the package folder.
0.1.0 (first release on PyPI)
Functions
harvest()reads one PDF, or every PDF in a folder, and returns clean sentences and a drop log. A bad file in a folder does not stop the run. Eachsent_idstarts with the document number.extract_dois()finds the DOI of each paper: the first DOI on page 1, else on page 2. It returns one row per file and can save a CSV file.causenet()finds cause-effect pairs with the SocioCausaNet model.device="auto"uses an NVIDIA GPU, else an Apple GPU, else the CPU.standardize_constructs()andstandardize_groups()map spans and groups to ELSST concepts.pairwise_grouping(),clusterer()andlabel_groups()group and name the spans without a thesaurus.
Data in the package
- ELSST release 5 (CC BY-SA 4.0, © CESSDA) ships in the package.
taxonomy="elsst"needs no extra file.
Tests and release
- Fast tests run on each push, on Linux, Windows and macOS. Slow tests load the real models.
- A GitHub Release publishes the package to PyPI through Trusted Publishing.
Development
Set up
git clone https://github.com/rasoulnorouzi/theoryminer.git
cd theoryminer
python -m venv .venv
source .venv/bin/activate
pip install -e ".[notebooks,dev]"
The code uses plain functions with full docstrings. A step never deletes a row: it marks the row and writes the reason. Keep these two rules when you add a step.
Tests
pytest # the fast tests: no model download, a few seconds
pytest -m slow # the tests that load the real models (about 1 GB on the first run)
pytest tests/test_harvest.py -k folder # one test by name
GitHub Actions runs the fast tests on each push, on Linux, Windows and macOS. It also builds the package and tests the built wheel.
Benchmarks
| Benchmark | Command | What it measures |
|---|---|---|
| Downloads | python benchmarks/downloader/run_benchmark.py --label my_run --email you@your-uni.edu | the share of correct PDFs on a fixed set of 300 DOIs |
| Map speed | python benchmarks/map/run_benchmark.py <out_folder> | the time to build and open maps of 300 to 10,000 nodes |
This documentation
The book uses mdBook. The pages are Markdown files in
docs/src/. The reference pages come from the docstrings: a script writes them before each build,
so the docstrings stay the only source.
python docs/make_api.py # write docs/src/reference/ from the docstrings
mdbook serve docs --open # build the book and open it; it builds again when a file changes
To get mdbook, download it from its
release page, or run cargo install mdbook.
A push to main builds the book and publishes it with GitHub Pages
(.github/workflows/docs.yml).
Releases
A push to main never publishes to PyPI. A release has three steps:
- Set the new version in
pyproject.toml, and in the first cell oftutorials/TUTORIAL.ipynb(%pip install -q "theoryminer==<version>"). A test checks that the two match. Add a section toCHANGELOG.md. Push, and wait for a green CI. - On GitHub, open Releases → Draft a new release. Give the tag
vplus the version, for examplev0.3.0. Click Generate release notes, then Publish release. - In the Actions tab, open the “Publish to PyPI” run and approve it (Review deployments).
The workflow stops when the tag and the version are different. PyPI trusts the workflow through Trusted Publishing, so the repository stores no token.
Licence
The code has the MIT licence. See the file
LICENSE.
These files keep their own licence:
| File | Licence |
|---|---|
theoryminer/data/ELSST_R5.rdf.gz | ELSST, © CESSDA, CC BY-SA 4.0. See theoryminer/data/ELSST_LICENSE.md. |
sample_files/*.pdf | open-access papers, CC BY 4.0. See sample_files/SOURCES.md. |
theoryminer/data/cytoscape.min.js and the fcose layout files (layout-base.js, cose-base.js, cytoscape-fcose.js) | MIT. Each page that draw_map() writes holds a copy, with the licence text. |
The papers that you download
download_papers() downloads open-access copies, and it obeys robots.txt by default. The
licence of each paper is in the licence column of its result. A licence such as CC BY permits
you to share the text; other licences do not. Check the licence before you publish the text of a
paper, for example in a notebook output.