This notebook reads the title and abstract decisions and the full-text decisions exported from the screening tool, checks them against the deduplicated records, and counts the sources at each stage. It adds the screening, included, and by_block sections to data/processed/screening_counts.yaml, and writes the included reports to data/processed/included_reports.csv. 04 Synthesis draws the flow diagram from that file.
The export formats are in the screening guide. A record is a title and abstract. A report is a full-text document. A source of evidence can have several reports.
The checks come first. If any check finds an error, the notebook stops and writes nothing, so the counts never rest on data that do not add up. A warning is printed and does not stop the run. When reviewers export their own decisions as well, the notebook reports their agreement.
Code
import refrom pathlib import Pathimport pandas as pdimport yamlROOT =next(p for p in [Path.cwd(), *Path.cwd().parents] if (p /"_quarto.yml").exists())PROC = ROOT /"data"/"processed"EXP = ROOT /"data"/"raw"/"screening_exports"dedup_path = PROC /"deduplicated_records.csv"ta_path = EXP /"title_abstract.csv"ft_path = EXP /"full_text.csv"counts_path = PROC /"screening_counts.yaml"REASONS = [r["code"] for r in yaml.safe_load((ROOT /"codebook.yml").read_text(encoding="utf-8"))["exclusion_reasons"]]missing = [str(p.relative_to(ROOT)) for p in (dedup_path, ta_path) ifnot p.exists()]have_data =not missingifnot have_data:print("Not run yet. Missing: "+", ".join(missing))def read(path, required, optional=()): df = pd.read_csv(path, dtype=str).fillna("") df.columns = [c.strip().lower() for c in df.columns] absent = [c for c in required if c notin df.columns]if absent:raiseValueError(f"{path.name} is missing column(s): {', '.join(absent)}")for c in optional:if c notin df.columns: df[c] =""return df.apply(lambda s: s.str.strip())def sample(values, n=5): values =sorted(set(values))return", ".join(values[:n]) + (f" and {len(values) - n} more"iflen(values) > n else"")
Not run yet. Missing: data/processed/deduplicated_records.csv, data/raw/screening_exports/title_abstract.csv
Code
if have_data: dedup = read(dedup_path, ["record_id"], ["blocks"]) ta = read(ta_path, ["record_id", "decision"]) ta["decision"] = ta["decision"].str.lower() ft_cols = ["record_id", "source_id", "retrieved", "decision", "reason"]if ft_path.exists(): ft = read(ft_path, ["record_id", "retrieved", "decision", "reason"], ["source_id"])else: ft = pd.DataFrame({c: pd.Series(dtype=str) for c in ft_cols})for col in ["retrieved", "decision", "reason"]: ft[col] = ft[col].str.lower() ft["source_id"] = ft["source_id"].where(ft["source_id"] !="", ft["record_id"])# Optional files with each reviewer's own decisions, for example title_abstract_r1.csv reviewers = {"title_abstract": {}, "full_text": {}}for p insorted(EXP.glob("*.csv")): m = re.fullmatch(r"(title_abstract|full_text)_(.+)", p.stem)if m: r = read(p, ["record_id", "decision"]) r["decision"] = r["decision"].str.lower() reviewers[m[1]][m[2]] = r[["record_id", "decision"]]print(f"{len(dedup)} deduplicated record(s), {len(ta)} title/abstract decision(s), {len(ft)} full-text row(s).")for stage, found in reviewers.items():if found:print(f"Reviewer files for {stage}: {', '.join(found)}")
Checks
Code
if have_data: errors, warnings = [], [] dedup_ids =set(dedup["record_id"]) ok = ["include", "exclude"]# Title and abstractif ta["record_id"].duplicated().any(): errors.append(f"title_abstract.csv has record_id values more than once: {sample(ta.loc[ta['record_id'].duplicated(), 'record_id'])}.") unknown =set(ta["record_id"]) - dedup_idsif unknown: errors.append(f"title_abstract.csv has {len(unknown)} record_id value(s) that are not in deduplicated_records.csv: {sample(unknown)}.") bad = ta[~ta["decision"].isin(ok)]iflen(bad): errors.append(f"title_abstract.csv has {len(bad)} row(s) with a decision other than include or exclude.") unscreened = dedup_ids -set(ta["record_id"])if unscreened: errors.append(f"{len(unscreened)} deduplicated record(s) have no title/abstract decision: {sample(unscreened)}.") sought_ids =set(ta.loc[ta["decision"] =="include", "record_id"])# Full textif ft["record_id"].duplicated().any(): errors.append(f"full_text.csv has record_id values more than once: {sample(ft.loc[ft['record_id'].duplicated(), 'record_id'])}.") stray =set(ft["record_id"]) - sought_idsif stray: errors.append(f"full_text.csv has {len(stray)} record_id value(s) that were not included at title and abstract: {sample(stray)}.") bad = ft[~ft["retrieved"].isin(["yes", "no"])]iflen(bad): errors.append(f"full_text.csv has {len(bad)} row(s) where retrieved is not yes or no.") bad = ft[(ft["retrieved"] =="yes") &~ft["decision"].isin(ok)]iflen(bad): errors.append(f"full_text.csv has {len(bad)} retrieved report(s) without an include or exclude decision.") bad = ft[(ft["retrieved"] =="no") & (ft["decision"] !="")]iflen(bad): errors.append(f"full_text.csv has {len(bad)} report(s) that were not retrieved but have a decision.") excluded = ft[(ft["retrieved"] =="yes") & (ft["decision"] =="exclude")]if (excluded["reason"] =="").any(): errors.append(f"full_text.csv has {int((excluded['reason'] =='').sum())} excluded report(s) without a reason.") odd =set(excluded["reason"]) -set(REASONS) - {""}if odd: errors.append(f"full_text.csv has exclusion reasons that are not in codebook.yml: {sample(odd)}.") pending = sought_ids -set(ft["record_id"])if pending: errors.append(f"{len(pending)} record(s) included at title and abstract have no full-text row: {sample(pending)}.")# Reviewer filesfor stage, found in reviewers.items():for name, r in found.items(): label =f"{stage}_{name}.csv"if r["record_id"].duplicated().any(): errors.append(f"{label} has record_id values more than once.") unknown =set(r["record_id"]) - dedup_idsif unknown: errors.append(f"{label} has {len(unknown)} record_id value(s) that are not in deduplicated_records.csv: {sample(unknown)}.") bad = r[~r["decision"].isin(ok)]iflen(bad): errors.append(f"{label} has {len(bad)} row(s) with a decision other than include or exclude.")for w in warnings:print("Warning:", w)if errors:raiseValueError("Fix these problems in the screening exports, then run this notebook again:\n- "+"\n- ".join(errors))
Code
if have_data: screened =len(ta) ta_excluded =int((ta["decision"] =="exclude").sum()) sought =len(sought_ids) not_retrieved =int((ft["retrieved"] =="no").sum()) ft_ok = ft[ft["retrieved"] =="yes"] assessed =len(ft_ok) ft_excl = ft_ok[ft_ok["decision"] =="exclude"] ft_incl = ft_ok[ft_ok["decision"] =="include"] reasons = ft_excl["reason"].value_counts() included_reports =len(ft_incl) included_sources =int(ft_incl["source_id"].nunique())# These hold by construction once the checks pass. They guard against a later edit breaking that.assert screened ==len(dedup), "Every deduplicated record must be screened."assert screened == ta_excluded + soughtassert sought == not_retrieved + assessedassert assessed ==len(ft_excl) + included_reportsassert included_sources <= included_reportsprint("No problems found.")
Agreement between reviewers
With two or more reviewer files for a stage, each pair of reviewers is compared on the records they both decided. Cohen’s kappa corrects the percent agreement for the agreement expected by chance. A reviewer file may cover only a sample, such as a calibration set. The final decisions in title_abstract.csv and full_text.csv are what the counts use.
Code
def kappa(a, b): po =float((a == b).mean()) pe =sum(float((a == label).mean() * (b == label).mean()) for label in ("include", "exclude"))returnfloat("nan") if pe ==1else (po - pe) / (1- pe)if have_data: final = {"title_abstract": ta.set_index("record_id")["decision"], "full_text": ft[ft["decision"] !=""].set_index("record_id")["decision"]} agreement = []for stage, found in reviewers.items(): names =sorted(found)for k, a inenumerate(names):for b in names[k +1:]: both = found[a].merge(found[b], on="record_id", suffixes=("_a", "_b"))if both.empty:continue agreement.append({"stage": stage, "reviewer_a": a, "reviewer_b": b,"n_compared": len(both),"percent_agreement": round(100*float((both["decision_a"] == both["decision_b"]).mean()), 1),"kappa": round(kappa(both["decision_a"], both["decision_b"]), 3), })if names: wide = pd.concat({n: found[n].set_index("record_id")["decision"] for n in names}, axis=1) disagree = wide[wide.nunique(axis=1) >1] split = disagree.assign(final=final[stage].reindex(disagree.index)) same = wide[(wide.nunique(axis=1) ==1) & wide.notna().all(axis=1)] overridden =int((final[stage].reindex(same.index) != same.iloc[:, 0]).sum()) iflen(same) else0print(f"{stage}: {len(split)} record(s) with a disagreement. {len(split[split['final'].isna()])} of them have no final decision.")if overridden:print(f"Warning: {overridden} record(s) have a final decision that differs from the decision all reviewers agreed on.")iflen(split): display(split.head(50))if agreement: display(pd.DataFrame(agreement))else:print("No pair of reviewer files to compare.")
Counts
Code
if have_data: summary = pd.DataFrame( [ ("Records screened", screened), ("Records excluded at title and abstract", ta_excluded), ("Reports sought for retrieval", sought), ("Reports not retrieved", not_retrieved), ("Reports assessed for eligibility", assessed), ("Reports excluded at full text", len(ft_excl)), ("Reports included", included_reports), ("Sources of evidence included", included_sources), ], columns=["Stage", "n"], ) display(summary)iflen(reasons): display(reasons.rename_axis("Exclusion reason").reset_index(name="n"))
Code
if have_data: block_ids = {}for rid, blocks inzip(dedup["record_id"], dedup["blocks"]):for b in [x.strip() for x in blocks.split(";") if x.strip()]: block_ids.setdefault(b, set()).add(rid) ta_excl_ids = ta.loc[ta["decision"] =="exclude", "record_id"] by_block = {}for b, ids insorted(block_ids.items()): by_block[b] = {"records_identified": len(ids),"records_screened": int(ta["record_id"].isin(ids).sum()),"records_excluded": int(ta_excl_ids.isin(ids).sum()),"reports_assessed": int(ft_ok["record_id"].isin(ids).sum()),"reports_excluded": int(ft_excl["record_id"].isin(ids).sum()),"reports_included": int(ft_incl["record_id"].isin(ids).sum()), } counts = (yaml.safe_load(counts_path.read_text()) or {}) if counts_path.exists() else {} counts["screening"] = {"records_screened": screened,"records_excluded": ta_excluded,"reports_sought": sought,"reports_not_retrieved": not_retrieved,"reports_assessed": assessed,"reports_excluded": [{"reason": r, "n": int(n)} for r, n in reasons.items()], } counts["included"] = {"sources": included_sources, "reports": included_reports} counts["by_block"] = by_blockif agreement: counts["agreement"] = agreementelse: counts.pop("agreement", None) counts_path.write_text(yaml.safe_dump(counts, sort_keys=False)) ft_incl[["source_id", "record_id"]].sort_values(["source_id", "record_id"]).to_csv(PROC /"included_reports.csv", index=False)print("Wrote data/processed/screening_counts.yaml and included_reports.csv")if by_block: display(pd.DataFrame(by_block).T.rename_axis("block"))