04 Synthesis

This notebook draws the PRISMA flow diagram from data/processed/screening_counts.yaml and summarizes the charted sources and factors from data/processed/included_sources_clean.csv and data/processed/included_factors_clean.csv. It saves the diagram as data/processed/prisma_flow.png.

The diagram follows the PRISMA 2020 template (Page et al. 2021), with “sources of evidence” in place of “studies” as PRISMA-ScR uses the term (Tricco et al. 2018). Records from other methods are deduplicated against the database records and screened in the same pass, so the diagram shows one path.

The counts are checked before the diagram is drawn. If they do not add up, the notebook stops with an error.

Code
import textwrap
from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator
import pandas as pd
import yaml
from matplotlib.patches import FancyBboxPatch

ROOT = next(p for p in [Path.cwd(), *Path.cwd().parents] if (p / "_quarto.yml").exists())
OUT = ROOT / "data" / "processed"

counts_path = OUT / "screening_counts.yaml"
counts = (yaml.safe_load(counts_path.read_text()) or {}) if counts_path.exists() else {}
have_flow = all(k in counts for k in ("identification", "screening", "included"))
if not have_flow:
    print("The flow diagram needs the output of notebooks 01 and 02.")
The flow diagram needs the output of notebooks 01 and 02.

Checks

Code
def check_flow(c):
    ident, scr, inc = c["identification"], c["screening"], c["included"]
    identified = sum(d["n"] for d in ident.get("databases", [])) + sum(d["n"] for d in ident.get("other_methods", []))
    removed = (
        ident.get("duplicates_removed", 0)
        + ident.get("marked_ineligible_by_automation", 0)
        + ident.get("removed_other_reasons", 0)
    )
    excluded_at_full_text = sum(r["n"] for r in scr.get("reports_excluded", []))
    equal = [
        ("Records identified minus records removed before screening equals records screened",
         identified - removed, scr["records_screened"]),
        ("Records screened equals records excluded plus reports sought",
         scr["records_screened"], scr["records_excluded"] + scr["reports_sought"]),
        ("Reports sought equals reports not retrieved plus reports assessed",
         scr["reports_sought"], scr["reports_not_retrieved"] + scr["reports_assessed"]),
        ("Reports assessed equals reports excluded plus reports included",
         scr["reports_assessed"], excluded_at_full_text + inc["reports"]),
    ]
    problems = [f"{rule}: {a} against {b}." for rule, a, b in equal if a != b]
    if inc["sources"] > inc["reports"]:
        problems.append(f"Sources included ({inc['sources']}) exceed reports included ({inc['reports']}).")
    return problems


if have_flow:
    problems = check_flow(counts)
    if problems:
        raise ValueError(
            "The flow counts do not add up. Run notebooks 01 and 02 again, in order, and check screening_counts.yaml:\n- "
            + "\n- ".join(problems)
        )
    print("The flow counts add up.")

Flow diagram

Code
def wrap(lines, width=34):
    out = []
    for line in lines:
        out.extend(textwrap.wrap(line, width) or [""])
    return out


def box_height(lines):
    return 0.30 * len(lines) + 0.35


def draw_box(ax, cx, cy, lines, width):
    h = box_height(lines)
    ax.add_patch(FancyBboxPatch(
        (cx - width / 2, cy - h / 2), width, h,
        boxstyle="round,pad=0.02,rounding_size=0.08", fc="white", ec="black", lw=1,
    ))
    ax.text(cx, cy, "\n".join(lines), ha="center", va="center", fontsize=9, linespacing=1.4)
    return h


def arrow(ax, start, end):
    ax.annotate("", xy=end, xytext=start, arrowprops=dict(arrowstyle="-|>", lw=1, color="black"))


def flow_rows(c):
    ident, scr, inc = c["identification"], c["screening"], c["included"]
    dbs, others = ident.get("databases", []), ident.get("other_methods", [])
    total = sum(d["n"] for d in dbs) + sum(d["n"] for d in others)
    first = ["Records identified", f"(n = {total})"]
    first += [f"{d['name']}{' [' + d['block'] + ']' if d.get('block') else ''} (n = {d['n']})" for d in dbs]
    first += [f"other: {d['name']} (n = {d['n']})" for d in others]
    removed = [
        "Records removed before screening",
        f"Duplicate records removed (n = {ident.get('duplicates_removed', 0)})",
        f"Marked ineligible by automation tools (n = {ident.get('marked_ineligible_by_automation', 0)})",
        f"Removed for other reasons (n = {ident.get('removed_other_reasons', 0)})",
    ]
    reasons = scr.get("reports_excluded", [])
    excl_total = sum(r["n"] for r in reasons)
    return [
        (first, removed),
        (["Records screened", f"(n = {scr['records_screened']})"], ["Records excluded", f"(n = {scr['records_excluded']})"]),
        (["Reports sought for retrieval", f"(n = {scr['reports_sought']})"], ["Reports not retrieved", f"(n = {scr['reports_not_retrieved']})"]),
        (["Reports assessed for eligibility", f"(n = {scr['reports_assessed']})"],
         ["Reports excluded", f"(n = {excl_total})"] + [f"{r['reason']} (n = {r['n']})" for r in reasons]),
        (["Sources of evidence included in the review", f"(n = {inc['sources']})",
          "Reports of included sources", f"(n = {inc['reports']})"], None),
    ]


def draw_flow(c, path):
    rows = [(wrap(m), wrap(s, 38) if s else None) for m, s in flow_rows(c)]
    main_w, side_w, gap = 4.4, 5.0, 0.8
    x_main, x_side = 3.0, 9.0
    heights = [max(box_height(m), box_height(s) if s else 0) for m, s in rows]
    total = sum(heights) + gap * (len(rows) - 1) + 1
    fig, ax = plt.subplots(figsize=(13.2 * 0.7, total * 0.7))
    ax.set_xlim(-1.2, 12)
    ax.set_ylim(0, total)
    ax.set_aspect("equal")
    ax.axis("off")
    y, previous, centers = total - 0.5, None, []
    for (m, s), h in zip(rows, heights):
        cy = y - h / 2
        mh = draw_box(ax, x_main, cy, m, main_w)
        if previous:
            arrow(ax, previous, (x_main, cy + mh / 2))
        if s:
            draw_box(ax, x_side, cy, s, side_w)
            arrow(ax, (x_main + main_w / 2, cy), (x_side - side_w / 2, cy))
        previous = (x_main, cy - mh / 2)
        centers.append((cy, h))
        y -= h + gap
    for label, (a, b) in {"Identification": (0, 0), "Screening": (1, 3), "Included": (4, 4)}.items():
        top = centers[a][0] + centers[a][1] / 2
        bottom = centers[b][0] - centers[b][1] / 2
        ax.plot([-0.4, -0.4], [bottom, top], color="black", lw=1)
        ax.text(-0.8, (top + bottom) / 2, label, rotation=90, ha="center", va="center", fontsize=10, fontweight="bold")
    fig.savefig(path, dpi=200, bbox_inches="tight")
    plt.show()


if have_flow:
    draw_flow(counts, OUT / "prisma_flow.png")

Charted sources

Code
clean_path, fac_path, bib_path = OUT / "included_sources_clean.csv", OUT / "included_factors_clean.csv", OUT / "bibliography.csv"
have_sources = clean_path.exists() and bib_path.exists()
if have_sources:
    clean = pd.read_csv(clean_path, dtype=str).fillna("")
    bib = pd.read_csv(bib_path, dtype=str).fillna("")
    factors = pd.read_csv(fac_path, dtype=str).fillna("") if fac_path.exists() else pd.DataFrame(columns=["source_id", "factor_category"])
    data = clean.merge(bib[["source_id", "year"]].drop_duplicates("source_id"), on="source_id", how="left").fillna("")
    print(f"{len(data)} charted source(s), {len(factors)} charted factor(s).")
else:
    print("The charts need the output of notebook 03.")
The charts need the output of notebook 03.
Code
if have_sources:
    fig, ax = plt.subplots(figsize=(8, 3.5))
    pd.crosstab(data["year"].replace("", "unknown"), data["field"]).plot.bar(stacked=True, ax=ax)
    ax.set_xlabel("Publication year")
    ax.set_ylabel("Sources")
    ax.set_title("Sources per year, by field")
    ax.yaxis.set_major_locator(MaxNLocator(integer=True))
    ax.legend(title="Field", loc="upper left", bbox_to_anchor=(1, 1))
    plt.tight_layout()
    plt.show()
Code
if have_sources:
    coded = factors[factors["factor_category"] != ""].merge(data[["source_id", "field"]], on="source_id")
    if len(coded):
        # A source counts once in a category, however many of its factors fall in it.
        table = coded.groupby(["factor_category", "field"])["source_id"].nunique().unstack(fill_value=0)
        fig, ax = plt.subplots(figsize=(8, 3.5))
        table.plot.bar(ax=ax)
        ax.set_xlabel("Factor category")
        ax.set_ylabel("Sources")
        ax.set_title("Sources reporting each factor category, by field")
        ax.yaxis.set_major_locator(MaxNLocator(integer=True))
        ax.legend(title="Field", loc="upper left", bbox_to_anchor=(1, 1))
        plt.xticks(rotation=30, ha="right")
        plt.tight_layout()
        plt.show()
    else:
        print("No factor categories charted yet.")
Code
if have_sources:
    fig, ax = plt.subplots(figsize=(8, 2.5))
    data["source_type"].value_counts().sort_values().plot.barh(ax=ax)
    ax.set_xlabel("Sources")
    ax.set_title("Source types")
    ax.xaxis.set_major_locator(MaxNLocator(integer=True))
    plt.tight_layout()
    plt.show()

References

Page, M. J., J. E. McKenzie, P. M. Bossuyt, et al. 2021. “The PRISMA 2020 Statement: An Updated Guideline for Reporting Systematic Reviews.” BMJ 372: n71. https://doi.org/10.1136/bmj.n71.
Tricco, Andrea C., Erin Lillie, Wasifa Zarin, et al. 2018. “PRISMA Extension for Scoping Reviews (PRISMA-ScR): Checklist and Explanation.” Annals of Internal Medicine 169 (7): 467–73. https://doi.org/10.7326/M18-0850.