Note
Go to the end to download the full example code.
Forward Inference From a Free-Text Query.#
NeuroSynth associates studies with the terms of a fixed vocabulary through
TF-IDF counts. The neurosynth engine also embeds the title and abstract
of every study with SPECTER2 and exposes the cosine similarity to any
free-text query as a function:
similarity(s, sim) :- study(s) & (
sim == query_similarity("visual word form reading", s)
).
query_similarity(query, s) returns the similarity of study s to the
query. The query text is embedded once and the similarities of all the
studies are cached, so each call is a lookup. The query can also be a
variable of a rule, bound by the rule that calls it:
close(q, s) :- study(s) & (sim == query_similarity(q, s)) & (sim > .75).
ans(s) :- close("visual word form reading", s).
This example lists the studies closest to “visual word form reading”, selects as many of them as there are studies tagged with the term ‘reading’, and compares the forward-inference maps \(P(\text{activation} \mid \text{study selected})\) of both selections.
The first run downloads the titles and abstracts from PubMed and embeds
them (see Semantic Search Over Study Abstracts); it needs
pip install "neurolang[embeddings]".
import warnings
warnings.filterwarnings("ignore")
import time
from pathlib import Path
from typing import Iterable
import matplotlib.pyplot as plt
import nibabel
import nilearn.datasets
import nilearn.image
import nilearn.plotting
import numpy as np
from neurolang.frontend.neurosynth_utils import StudyID, fetch_study_metadata
from neurolang.utils.engine_registry import build_engine
QUERY = "visual word form reading"
TERM = "reading"
Build the NeuroSynth engine#
Peaks are mapped to 4 mm voxels. The engine declares the
query_similarity(query, study) function in its YAML configuration;
nothing is embedded until a query uses it.
data_dir = Path.home() / "neurolang_data"
start = time.perf_counter()
nl = build_engine("neurosynth", data_dir, resolution=4)
print(f"Engine built in {time.perf_counter() - start:.1f}s")
mni_t1 = nibabel.load(
nilearn.datasets.fetch_icbm152_2009(data_dir=str(data_dir / "icbm"))["t1"]
)
mni_t1_4mm = nilearn.image.resample_img(mni_t1, np.eye(3) * 4)
Study titles come from the NeuroSynth metadata.
metadata = fetch_study_metadata(data_dir, verbose=0)
titles = metadata[["id", "title"]].drop_duplicates("id")
titles = titles.astype({"id": StudyID})
nl.add_tuple_set(titles, name="study_title")
Studies closest to the query#
The first rule gives the similarity of every study to the query; the answer joins it with the titles.
start = time.perf_counter()
top = nl.execute_datalog_program(
f"""
similarity(s, v) :- study(s) & (v == query_similarity('{QUERY}', s)).
reading_study(s) :- term_in_study_tfidf(s, '{TERM}', w).
ans(s, v, t) :- similarity(s, v) & study_title(s, t).
"""
)
print(f"Similarity query: {time.perf_counter() - start:.1f}s")
top = top.as_pandas_dataframe()
top.columns = ["study", "similarity", "title"]
top = top.sort_values("similarity", ascending=False)
reading = nl.execute_datalog_program("ans(s) :- reading_study(s).")
reading = set(reading.as_pandas_dataframe().iloc[:, 0])
top["tagged"] = top.study.isin(reading)
print(
f"{len(top)} studies, similarity mean {top.similarity.mean():.3f}, "
f"sd {top.similarity.std():.3f}; {len(reading)} tagged '{TERM}'"
)
for n in (20, 100, len(reading)):
print(f"top {n}: {top.tagged[:n].sum()} tagged '{TERM}'")
for _, row in top.head(10).iterrows():
mark = "*" if row.tagged else " "
print(f"{row.similarity:.3f} {mark} {row.study} {row.title[:70]}")
Selecting studies in Datalog#
The text selection keeps as many studies as there are studies tagged
with the term: the cutoff is the n-th largest similarity, computed by
an aggregation over the similarity rows.
@nl.add_symbol
def nth_largest(values: Iterable, n: Iterable) -> float:
"""Return the ``n``-th largest of ``values``."""
return float(np.sort(np.asarray(values))[-int(np.asarray(n)[0])])
nl.execute_datalog_program(
"""
n_reading(count(s)) :- reading_study(s).
cutoff(nth_largest(v, n)) :- similarity(s, v) & n_reading(n).
text_study(s) :- similarity(s, v) & cutoff(c) & (v >= c).
"""
)
Forward-inference maps#
selected_study is a uniform choice over the studies. Each map is the
probability that the selected study reports a peak in a voxel, given
that it belongs to a selection.
nl.execute_datalog_program(
"""
activation(i, j, k) :- selected_study(s) & peak_reported(i, j, k, s).
in_text(s) :- selected_study(s) & text_study(s).
in_term(s) :- selected_study(s) & reading_study(s).
text_map(i, j, k, PROB) :- activation(i, j, k) // in_text(s).
term_map(i, j, k, PROB) :- activation(i, j, k) // in_term(s).
"""
)
# Each study contributes only the voxels of its peaks, so the maps are
# smoothed with an 8 mm Gaussian kernel, as for coordinate-based
# meta-analysis maps, before they are compared and plotted.
SMOOTHING_FWHM = 8
def solve_map(name):
"""Solve a MARG query and return its smoothed probability image."""
start = time.perf_counter()
res = nl.execute_datalog_program(f"ans(i, j, k, p) :- {name}(i, j, k, p).")
print(f"{name}: {time.perf_counter() - start:.1f}s")
ijkp = res.as_pandas_dataframe().values
ijk = ijkp[:, :3].astype(int)
# A few peaks fall outside the MNI grid.
inside = np.all((ijk >= 0) & (ijk < mni_t1_4mm.shape), axis=1)
data = np.zeros(mni_t1_4mm.shape)
data[tuple(ijk[inside].T)] = ijkp[inside, 3]
image = nibabel.Nifti1Image(data, mni_t1_4mm.affine)
return nilearn.image.smooth_img(image, SMOOTHING_FWHM)
maps = {
f"top {len(reading)} studies by similarity": solve_map("text_map"),
f"studies tagged '{TERM}'": solve_map("term_map"),
}
Agreement with the term map#
Pearson correlation over the voxels where either map is positive, and Dice overlap of the top 5% of these voxels.
term = maps[f"studies tagged '{TERM}'"].get_fdata()
def agreement(image):
"""Return the correlation and top-5% Dice with the term map."""
data = image.get_fdata()
mask = (data > 0) | (term > 0)
a, b = data[mask], term[mask]
top_a = a > np.quantile(a, 0.95)
top_b = b > np.quantile(b, 0.95)
dice = 2 * (top_a & top_b).sum() / (top_a.sum() + top_b.sum())
return np.corrcoef(a, b)[0, 1], dice
labels = {title: title for title in maps}
text_title = f"top {len(reading)} studies by similarity"
r, dice = agreement(maps[text_title])
print(f"{text_title}: r = {r:.2f}, Dice top 5% = {dice:.2f}")
labels[text_title] += f"\nr = {r:.2f}, Dice top 5% = {dice:.2f}"
Plot#
Voxels above the 95th percentile of each map, interpolated to 2 mm for display.
fig, axes = plt.subplots(1, len(maps), figsize=(6 * len(maps), 3))
for ax, (title, image) in zip(axes, maps.items()):
data = image.get_fdata()
nilearn.plotting.plot_glass_brain(
nilearn.image.resample_img(
image, np.eye(3) * 2, interpolation="continuous"
),
threshold=np.quantile(data[data > 0], 0.95),
axes=ax,
display_mode="ortho",
)
ax.set_title(labels[title], fontsize=11)
nilearn.plotting.show()