Clinical Assessment Scoring and Interpretation
TL;DR — Batch-score PHQ-9, GAD-7, PCL-5, BDI-II, and SWLS questionnaires.
Compute the Reliable Change Index (RCI) for clinically significant change,
handle missing items with prorated scoring, compare against published norms,
and generate longitudinal trajectory visualizations.
When to Use
Use this Skill when you need to:
- Score clinical questionnaires from raw item responses in a data frame
- Apply standard severity cutoffs (minimal, mild, moderate, severe)
- Determine whether pre-post change is statistically reliable (RCI)
- Classify individuals as recovered, improved, unchanged, or deteriorated
- Compute prorated scores when 1–2 items are missing
- Generate per-participant longitudinal plots showing clinical trajectories
- Compare scores to published normative samples (z-score lookup)
Background
Scoring Rules Summary
| Scale | Items | Range | Cutoffs |
|---|
| PHQ-9 | 9 items, 0–3 each | 0–27 | 0–4 minimal, 5–9 mild, 10–14 moderate, 15–27 severe |
| GAD-7 | 7 items, 0–3 each | 0–21 | 0–4 minimal, 5–9 mild, 10–14 moderate, 15–21 severe |
| PCL-5 | 20 items, 0–4 each | 0–80 | ≥ 33 provisional PTSD |
| BDI-II | 21 items, 0–3 each | 0–63 | 0–13 minimal, 14–19 mild, 20–28 moderate, 29–63 severe |
| SWLS | 5 items, 1–7 each | 5–35 | 5–9 extremely dissatisfied, ≥31 extremely satisfied |
PCL-5 DSM-5 Cluster Subscores
| Cluster | Items (1-indexed) | Symptom Group |
|---|
| B (Intrusion) | 1–5 | Re-experiencing |
| C (Avoidance) | 6–7 | Avoidance |
| D (Neg cognition) | 8–14 | Negative alterations in cognition/mood |
| E (Hyperarousal) | 15–20 | Alterations in arousal/reactivity |
Reliable Change Index (RCI)
Jacobson & Truax (1991):
SE_diff = SD_pre × √(2) × √(1 − r_tt)
RCI = (post_score − pre_score) / SE_diff
Where r_tt is the test-retest reliability of the scale. Reliable change:
|RCI| ≥ 1.96 (two-tailed, α = .05).
Clinical significance requires BOTH reliable change AND movement from the
dysfunctional distribution (above cutoff) to the functional distribution
(below cutoff).
Environment Setup
conda create -n clinical_env python=3.11 -y
conda activate clinical_env
pip install pandas>=1.5 numpy>=1.23 matplotlib>=3.6 scipy>=1.9
python -c "import pandas, numpy, matplotlib, scipy; print('All OK')"
Core Workflow
Step 1 — Questionnaire Scoring
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
from scipy import stats
from typing import Optional, Dict, List, Tuple, Union
SCALE_RELIABILITY = {
"PHQ-9": 0.84,
"GAD-7": 0.83,
"PCL-5": 0.82,
"BDI-II": 0.93,
"SWLS": 0.82,
}
SCALE_NORM_SD = {
"PHQ-9": 7.1,
"GAD-7": 5.6,
"PCL-5": 22.0,
"BDI-II": 12.7,
"SWLS": 6.4,
}
SEVERITY_CUTOFFS = {
"PHQ-9": [(4, "minimal"), (9, "mild"), (, ), (, )],
: [(, ), (, ), (, ), (, )],
: [(, ), (, )],
: [(, ), (, ), (, ), (, )],
: [(, ), (, ),
(, ), (, ),
(, ), (, ), (, )],
}
CLINICAL_CUTOFF = {
: ,
: ,
: ,
: ,
: ,
}
() -> :
cutoffs = SEVERITY_CUTOFFS.get(scale, [])
max_val, label cutoffs:
score <= max_val:
label
() -> pd.DataFrame:
item_cols :
item_cols = [ i (, )]
(item_cols) == ,
result = df[[id_col]].copy() id_col df.columns df.iloc[:, :].copy()
result.columns = [id_col]
scores = []
severities = []
n_missing_list = []
_, row df.iterrows():
item_vals = row[item_cols]
n_valid = item_vals.notna().()
n_missing = - n_valid
n_missing > allow_missing:
total = np.nan
severity =
n_missing == :
total = item_vals.()
severity = apply_severity_cutoff(total, )
:
total = ((item_vals.() / n_valid) * )
severity = apply_severity_cutoff(total, )
scores.append(total)
severities.append(severity)
n_missing_list.append(n_missing)
result[] = scores
result[] = severities
result[] = n_missing_list
result[] = df[item_cols[]].values
result
() -> pd.DataFrame:
item_cols :
item_cols = [ i (, )]
(item_cols) ==
result = df[[id_col]].copy() id_col df.columns df.iloc[:, :].copy()
result.columns = [id_col]
scores, severities, n_missing_list = [], [], []
_, row df.iterrows():
vals = row[item_cols]
n_valid = vals.notna().()
n_missing = - n_valid
n_missing > allow_missing:
total, severity = np.nan,
n_missing == :
total = vals.()
severity = apply_severity_cutoff(total, )
:
total = ((vals.() / n_valid) * )
severity = apply_severity_cutoff(total, )
scores.append(total)
severities.append(severity)
n_missing_list.append(n_missing)
result[] = scores
result[] = severities
result[] = n_missing_list
result
() -> pd.DataFrame:
item_cols :
item_cols = [ i (, )]
(item_cols) ==
result = df[[id_col]].copy() id_col df.columns df.iloc[:, :].copy()
result.columns = [id_col]
clusters = {
: item_cols[:],
: item_cols[:],
: item_cols[:],
: item_cols[:],
}
result[] = df[item_cols].(axis=)
cluster_name, cols clusters.items():
result[] = df[cols].(axis=)
result[] = result[] >=
result[] = result[].apply(
x: apply_severity_cutoff(x, )
)
result
Step 2 — Reliable Change Index
def compute_rci(
pre_scores: np.ndarray,
post_scores: np.ndarray,
scale: str,
sd_pre: Optional[float] = None,
r_tt: Optional[float] = None,
clinical_cutoff: Optional[float] = None,
dysfunctional_above_cutoff: bool = True,
) -> pd.DataFrame:
"""
Compute Reliable Change Index (RCI) for pre-post score pairs.
Classification (Jacobson & Truax, 1991):
Recovered: Reliable improvement AND crossed clinical cutoff
Improved: Reliable improvement only
Unchanged: No reliable change (|RCI| < 1.96)
Deteriorated: Reliable worsening (RCI <= -1.96)
Args:
pre_scores: Array of pre-treatment scores.
post_scores: Array of post-treatment scores.
scale: Scale name (e.g., 'PHQ-9') for default SD and r_tt lookup.
sd_pre: SD of pre-treatment scores (overrides norm SD if provided).
r_tt: Test-retest reliability (overrides default if provided).
clinical_cutoff: Score threshold for functional vs dysfunctional range.
dysfunctional_above_cutoff: True if high scores = clinical (PHQ-9, GAD-7);
False if low scores = clinical (SWLS).
Returns:
DataFrame with RCI, classification, and pre/post severity.
"""
sd = sd_pre if sd_pre is not None else SCALE_NORM_SD.get(scale, 10.0)
rtt = r_tt if r_tt is not None else SCALE_RELIABILITY.get(scale, 0.85)
cutoff = clinical_cutoff if clinical_cutoff is not None else CLINICAL_CUTOFF.get(scale, None)
se_diff = sd * np.sqrt() * np.sqrt( - rtt)
rci_values = (post_scores - pre_scores) / se_diff
classifications = []
rci_val, pre, post (rci_values, pre_scores, post_scores):
rci_val <= -:
cutoff :
dysfunctional_above_cutoff:
crossed = pre >= cutoff post < cutoff
:
crossed = pre < cutoff post >= cutoff
cls = crossed
:
cls =
rci_val >= :
cls =
:
cls =
classifications.append(cls)
result_df = pd.DataFrame({
: pre_scores,
: post_scores,
: post_scores - pre_scores,
: np.(rci_values, ),
: classifications,
: [apply_severity_cutoff(s, scale) s pre_scores],
: [apply_severity_cutoff(s, scale) s post_scores],
})
class_counts = result_df[].value_counts()
n = (result_df)
()
cls [, , , ]:
count = class_counts.get(cls, )
()
result_df
Step 3 — Longitudinal Visualization
def plot_longitudinal_trajectories(
df_long: pd.DataFrame,
outcome_col: str,
time_col: str,
id_col: str,
scale_name: str = "",
highlight_clinical_cutoff: Optional[float] = None,
output_path: Optional[str] = None,
n_highlight: int = 5,
) -> plt.Figure:
"""
Plot individual longitudinal trajectories with group mean overlay.
Args:
df_long: Long-format DataFrame (one row per person × time).
outcome_col: Score column.
time_col: Time point column (numeric or ordered categorical).
id_col: Participant ID column.
scale_name: Scale label for plot title and y-axis.
highlight_clinical_cutoff: Draw a horizontal reference line at this score.
output_path: Optional path to save figure.
n_highlight: Number of individual trajectories to highlight.
Returns:
Matplotlib Figure.
"""
time_points = sorted(df_long[time_col].unique())
persons = df_long[id_col].unique()
rng = np.random.default_rng(42)
fig, ax = plt.subplots(figsize=(10, 6))
for person in persons:
pdata = df_long[df_long[id_col] == person].sort_values(time_col)
ax.plot(pdata[time_col], pdata[outcome_col],
color="lightgray", linewidth=0.8, alpha=0.5, zorder=1)
highlighted = rng.choice(persons, min(n_highlight, len(persons)), replace=False)
colors = plt.cm.tab10(np.linspace(0, , (highlighted)))
person, color (highlighted, colors):
pdata = df_long[df_long[id_col] == person].sort_values(time_col)
ax.plot(pdata[time_col], pdata[outcome_col],
color=color, linewidth=, alpha=, zorder=,
label=)
group_mean = df_long.groupby(time_col)[outcome_col].agg([, ]).reset_index()
ax.plot(group_mean[time_col], group_mean[],
color=, linewidth=, zorder=, label=)
ax.fill_between(
group_mean[time_col],
group_mean[] - * group_mean[],
group_mean[] + * group_mean[],
alpha=, color=, zorder=,
)
highlight_clinical_cutoff :
ax.axhline(highlight_clinical_cutoff, color=, linestyle=,
linewidth=, label=)
ax.set_xlabel()
ax.set_ylabel(scale_name outcome_col)
ax.set_title()
ax.legend(fontsize=, loc=)
ax.grid(alpha=)
fig.tight_layout()
output_path:
fig.savefig(output_path, dpi=)
plt.show()
fig
Advanced Usage
Batch Scoring with PHQ-9 + GAD-7 Combined
def batch_score_all_scales(
df: pd.DataFrame,
id_col: str = "participant_id",
phq9_items: Optional[List[str]] = None,
gad7_items: Optional[List[str]] = None,
pcl5_items: Optional[List[str]] = None,
) -> pd.DataFrame:
"""
Score PHQ-9, GAD-7, and PCL-5 in one call and merge results.
Args:
df: Wide-format DataFrame.
id_col: Participant ID column.
phq9_items: PHQ-9 item columns (defaults to phq1–phq9).
gad7_items: GAD-7 item columns (defaults to gad1–gad7).
pcl5_items: PCL-5 item columns (defaults to pcl1–pcl20).
Returns:
DataFrame with all scale scores merged on id_col.
"""
phq9_df = score_phq9(df, item_cols=phq9_items, id_col=id_col)
gad7_df = score_gad7(df, item_cols=gad7_items, id_col=id_col)
pcl5_df = score_pcl5(df, item_cols=pcl5_items, id_col=id_col)
merged = phq9_df.merge(gad7_df, on=id_col).merge(pcl5_df, on=id_col)
merged["comorbid_dep_anx"] = (
(merged["PHQ9_total"] >= 10) & (merged["GAD7_total"] >= 10)
)
print(f"\nBatch scoring complete: {len(merged)} participants")
print(f"PHQ-9 ≥ 10 (moderate+): {(merged['PHQ9_total'] >= 10).sum()}")
print(f"GAD-7 ≥ 10 (moderate+): {(merged['GAD7_total'] >= 10).sum()}")
()
()
merged
Troubleshooting
| Problem | Likely Cause | Solution |
|---|
| Negative RCI for "worsening" with PHQ-9 | Convention: lower = better | Check rci_val >= 1.96 means deterioration |
| All classified as "Unchanged" | SD too large or wrong scale | Use clinical sample SD, not general population |
| Prorated score unexpectedly high | All non-missing items are high | Expected behavior; flag if > 2 items missing |
| PCL-5 cluster subscores don't sum to total | Rounding or missing items | Ensure no NaN; use sum(axis=1, min_count=20) |
| Longitudinal plot illegible | Too many participants | Reduce n_highlight or use mean + CI only |
| NaN in severity column | Score is NaN (too many missing) | Apply dropna() before severity lookup |
External Resources
- Kroenke, K., Spitzer, R. L., & Williams, J. B. W. (2001). The PHQ-9.
Journal of General Internal Medicine, 16(9), 606–613.
- Spitzer, R. L., et al. (2006). A brief measure for assessing GAD. JAMA Internal Medicine.
- Blevins, C. A., et al. (2015). PCL-5: Initial psychometric assessment.
Assessment, 22(5), 477–482.
- Jacobson, N. S., & Truax, P. (1991). Clinical significance. Journal of Consulting
and Clinical Psychology, 59(1), 12–19.
- Diener, E., et al. (1985). The Satisfaction with Life Scale. Journal of Personality
Assessment, 49(1), 71–75.
Examples
Example 1 — Batch PHQ-9 + GAD-7 Scoring with Cutoff Classification
import pandas as pd
import numpy as np
rng = np.random.default_rng(0)
n = 80
data = {
"participant_id": [f"P{i:03d}" for i in range(1, n + 1)],
}
for i in range(1, 10):
data[f"phq{i}"] = rng.integers(0, 4, n)
for i in range(1, 8):
data[f"gad{i}"] = rng.integers(0, 4, n)
for i in range(1, 21):
data[f"pcl{i}"] = rng.integers(0, 5, n)
data["phq3"][5] = np.nan
data["gad2"][12] = np.nan
df_raw = pd.DataFrame(data)
df_scored = batch_score_all_scales(
df_raw,
id_col="participant_id",
phq9_items=[f"phq" i (, )],
gad7_items=[ i (, )],
pcl5_items=[ i (, )],
)
()
(df_scored[[, , ,
, , ]].head())
fig, axes = plt.subplots(, , figsize=(, ))
ax, (col, title) (axes, [
(, ),
(, ),
(, ),
]):
counts = df_scored[col].value_counts()
ax.bar(counts.index, counts.values, color=, edgecolor=)
ax.set_title(title)
ax.set_ylabel()
ax.tick_params(axis=, rotation=)
fig.tight_layout()
plt.savefig(, dpi=)
plt.show()
Example 2 — RCI and Clinical Significance Classification
rng = np.random.default_rng(1)
n_pts = 60
pre = rng.normal(16, 6, n_pts).clip(0, 27).round()
post = (pre - rng.normal(5, 4, n_pts)).clip(0, 27).round()
rci_df = compute_rci(
pre_scores=pre,
post_scores=post,
scale="PHQ-9",
clinical_cutoff=10,
dysfunctional_above_cutoff=True,
)
print("\nRCI results sample:")
print(rci_df.head(10).to_string())
class_counts = rci_df["classification"].value_counts()
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
colors_map = {"Recovered": "#2ecc71", "Improved": "#3498db",
"Unchanged": "#f39c12", "Deteriorated": "#e74c3c"}
bars = axes[0].bar(class_counts.index,
class_counts.values,
color=[colors_map.get(c, "gray") for c in class_counts.index])
axes[0].set_title("Treatment Response Classification")
axes[0].set_ylabel("Number of Participants")
for bar, count in (bars, class_counts.values):
axes[].text(bar.get_x() + bar.get_width() / , bar.get_height() + ,
(count), ha=, va=, fontsize=)
scatter_colors = [colors_map.get(c, ) c rci_df[]]
axes[].scatter(rci_df[], rci_df[],
c=scatter_colors, alpha=, s=)
axes[].plot([, ], [, ], , linewidth=, label=)
axes[].axhline(, color=, linestyle=, linewidth=, label=)
axes[].axvline(, color=, linestyle=, linewidth=, label=)
axes[].set_xlabel()
axes[].set_ylabel()
axes[].set_title()
legend_patches = [mpatches.Patch(color=c, label=l) l, c colors_map.items()]
axes[].legend(handles=legend_patches, fontsize=)
matplotlib.patches mpatches
fig.tight_layout()
plt.savefig(, dpi=)
plt.show()
()
Changelog
| Version | Date | Change |
|---|
| 1.0.0 | 2026-03-18 | Initial release — PHQ-9/GAD-7/PCL-5 scoring, RCI, clinical significance, longitudinal plots |