*! version 1.0.0 11May2026 Plot CSEMs from gtcsem *! Companion post-estimation plot command for gtcsem.ado *! *! Renders Brennan-style plots of conditional SEMs against *! observed score, optionally with confidence bands and method *! comparisons. *! *! Author: Rene Gempp capture program drop gtcsem_plot program define gtcsem_plot version 16.0 syntax [, /// PLOT(string) /// ABSrel(string) /// PREfix(name) /// METHod(string) /// SMooth /// COMPare /// CI(real 95) /// CIBands(string) /// ASEmethod(string) /// SAVING(string) /// TITLE(string) /// SUBTITLE(string) /// XTitle(string) /// YTitle(string) /// Name(string) /// * /// pass-through twoway options ] /* ======================================================== Defaults and validation ======================================================== */ if "`plot'" == "" local plot "csem" if "`absrel'" == "" local absrel "relative" if "`asemethod'" == "" local asemethod "analytical" if "`method'" == "" local method "full" if "`cibands'" == "" local cibands "person" local plot = lower("`plot'") local absrel = lower("`absrel'") local asemethod = lower("`asemethod'") local method = lower("`method'") local cibands = lower("`cibands'") if !inlist("`plot'", "csem", "ci", "both") { di as error "plot() must be one of: csem, ci, both" exit 198 } if !inlist("`absrel'", "relative", "absolute", "both") { di as error "absrel() must be one of: relative, absolute, both" exit 198 } if !inlist("`asemethod'", "analytical", "bootstrap") { di as error "asemethod() must be one of: analytical, bootstrap" exit 198 } if !inlist("`method'", "full", "large_a", "uncorrelated") { di as error "method() must be one of: full, large_a, uncorrelated" exit 198 } if !inlist("`cibands'", "person", "model") { di as error "cibands() must be one of: person, model" exit 198 } if `ci' <= 0 | `ci' >= 100 { di as error "ci() must be strictly between 0 and 100." exit 198 } /* Read prefix from r(prefix) if not supplied; fall back to the dataset characteristic stored by gtcsem so that repeat plot calls keep working even after intervening r-class commands. */ local r_method "`r(method)'" if "`prefix'" == "" { local prefix "`r(prefix)'" if "`prefix'" == "" { local prefix : char _dta[gtcsem_prefix] } if "`prefix'" == "" { di as error "No prefix supplied and gtcsem state not found. " /// "Run gtcsem first or supply prefix() option." exit 198 } } if "`r_method'" == "" { local r_method : char _dta[gtcsem_method] } /* ======================================================== Verify required variables exist ======================================================== */ capture confirm variable `prefix'_score if _rc { di as error "Variable `prefix'_score not found. " /// "Did you run gtcsem with generate(`prefix') first?" exit 111 } /* Mapping from method to suffix used by gtcsem when method=all */ local sfx "" if "`r_method'" == "all" { if "`method'" == "full" local sfx "_full" if "`method'" == "large_a" local sfx "_la" if "`method'" == "uncorrelated" local sfx "_unc" } /* When compare requested, ensure all three methods exist */ if "`compare'" != "" { capture confirm variable `prefix'_rel_csem_full if _rc { di as error "compare requires gtcsem to have been called " /// "with method(all)." exit 111 } } /* ======================================================== Variable bookkeeping ======================================================== */ local x `prefix'_score local abs_y `prefix'_abs_csem local rel_y `prefix'_rel_csem`sfx' local abs_y_sm `prefix'_abs_csem_sm local rel_y_sm `prefix'_rel_csem_sm`sfx' /* If gtcsem was run with -excludeextremes-, the smoothed variables are missing for floor/ceiling cases. We use that missingness to filter the raw scatter as well, so that the displayed cloud and the fitted line cover the same sample. Without -excludeextremes-, `keep' is just `1'. */ local gtcsem_excl : char _dta[gtcsem_excludeextremes] tempvar keep if "`gtcsem_excl'" != "" { capture confirm variable `abs_y_sm' if !_rc { quietly gen byte `keep' = !missing(`abs_y_sm') } else { quietly gen byte `keep' = 1 } } else { quietly gen byte `keep' = 1 } if "`asemethod'" == "analytical" { local v_abs `prefix'_vabs_an local v_rel `prefix'_vrev_an`sfx' } else { local v_abs `prefix'_vabs_bs local v_rel `prefix'_vrev_bs`sfx' capture confirm variable `v_abs' if _rc { di as error "asemethod(bootstrap) requested, but `v_abs' not " /// "found. Did you run gtcsem with semethod(bootstrap) or both?" exit 111 } } /* ======================================================== Compute CI band variables (squared error variances are on the CSEM scale via sqrt). Delta-method approximation: Var[csem] = Var[ev] / (4 * ev) so SE[csem] = sqrt(Var[ev]) / (2 * sqrt(ev)) ======================================================== */ local zcrit = invnormal(1 - (1 - `ci'/100)/2) tempvar abs_se_csem rel_se_csem tempvar abs_lo abs_hi rel_lo rel_hi if "`plot'" == "ci" | "`plot'" == "both" { if "`cibands'" == "model" { /* Model-based bands: refit the quadratic smoother on the per-person _ev variables, use predict, stdp to obtain the SE of the mean fit, then convert to the CSEM scale via the delta method: SE[csem] = SE[ev_hat] / (2 * sqrt(ev_hat)). The refit is restricted to the same `keep' sample (non-extreme cases when -excludeextremes- was on). asemethod() is ignored under cibands(model). */ tempvar score2_m quietly generate double `score2_m' = `x'^2 if inlist("`absrel'", "absolute", "both") { tempvar yhat_a stdp_a csem_a quietly regress `prefix'_abs_ev `x' `score2_m' if `keep' quietly predict double `yhat_a' quietly predict double `stdp_a', stdp quietly generate double `csem_a' = /// sqrt(max(`yhat_a', 0)) quietly generate double `abs_se_csem' = /// cond(`csem_a' > 0, `stdp_a' / (2 * `csem_a'), .) quietly generate double `abs_lo' = /// max(0, `csem_a' - `zcrit' * `abs_se_csem') quietly generate double `abs_hi' = /// `csem_a' + `zcrit' * `abs_se_csem' } if inlist("`absrel'", "relative", "both") { tempvar yhat_r stdp_r csem_r quietly regress `prefix'_rel_ev`sfx' `x' `score2_m' if `keep' quietly predict double `yhat_r' quietly predict double `stdp_r', stdp quietly generate double `csem_r' = /// sqrt(max(`yhat_r', 0)) quietly generate double `rel_se_csem' = /// cond(`csem_r' > 0, `stdp_r' / (2 * `csem_r'), .) quietly generate double `rel_lo' = /// max(0, `csem_r' - `zcrit' * `rel_se_csem') quietly generate double `rel_hi' = /// `csem_r' + `zcrit' * `rel_se_csem' } } else { /* Person-level bands (default). Each person's CSEM gets its own CI from the per-person Var[ev_p]. */ if inlist("`absrel'", "absolute", "both") { quietly generate double `abs_se_csem' = /// cond(`abs_y' > 0, sqrt(`v_abs') / (2 * `abs_y'), .) quietly generate double `abs_lo' = /// max(0, `abs_y' - `zcrit' * `abs_se_csem') quietly generate double `abs_hi' = `abs_y' + `zcrit' * `abs_se_csem' } if inlist("`absrel'", "relative", "both") { quietly generate double `rel_se_csem' = /// cond(`rel_y' > 0, sqrt(`v_rel') / (2 * `rel_y'), .) quietly generate double `rel_lo' = /// max(0, `rel_y' - `zcrit' * `rel_se_csem') quietly generate double `rel_hi' = `rel_y' + `zcrit' * `rel_se_csem' } } } /* ======================================================== Default titles ======================================================== */ if "`xtitle'" == "" local xtitle "Observed score" if "`ytitle'" == "" local ytitle "Conditional SEM" if "`title'" == "" { if "`absrel'" == "absolute" local title "Absolute conditional SEM" else if "`absrel'" == "relative" local title "Relative conditional SEM" else local title "Conditional SEMs" } /* ======================================================== Construct the twoway command ======================================================== */ local plotcmds "" /* ----- Compare mode: three methods overlaid ----- */ if "`compare'" != "" { local plotcmds `plotcmds' /// (scatter `prefix'_rel_csem_full `x' if `keep', msymbol(O) /// msize(small) mcolor(navy)) /// (scatter `prefix'_rel_csem_la `x' if `keep', msymbol(D) /// msize(small) mcolor(maroon)) /// (scatter `prefix'_rel_csem_unc `x' if `keep', msymbol(T) /// msize(small) mcolor(forest_green)) local legend /// legend(order(1 "full" 2 "large_a" 3 "uncorrelated") rows(1)) if "`smooth'" != "" { capture confirm variable `prefix'_rel_csem_sm_full if !_rc { local plotcmds `plotcmds' /// (line `prefix'_rel_csem_sm_full `x' if `keep', sort lcolor(navy)) /// (line `prefix'_rel_csem_sm_la `x' if `keep', sort lcolor(maroon)) /// (line `prefix'_rel_csem_sm_unc `x' if `keep', sort lcolor(forest_green)) } } } /* ----- Standard plot ----- */ else { if "`plot'" == "ci" | "`plot'" == "both" { if inlist("`absrel'", "absolute", "both") { local plotcmds `plotcmds' /// (rarea `abs_lo' `abs_hi' `x' if `keep', sort /// color(navy%20) lwidth(none)) } if inlist("`absrel'", "relative", "both") { local plotcmds `plotcmds' /// (rarea `rel_lo' `rel_hi' `x' if `keep', sort /// color(maroon%20) lwidth(none)) } } if inlist("`absrel'", "absolute", "both") { if inlist("`plot'", "csem", "both") { local plotcmds `plotcmds' /// (scatter `abs_y' `x' if `keep', msymbol(O) msize(small) /// mcolor(navy)) } if "`smooth'" != "" { capture confirm variable `abs_y_sm' if !_rc { local plotcmds `plotcmds' /// (line `abs_y_sm' `x' if `keep', sort lcolor(navy) lwidth(medthick)) } } } if inlist("`absrel'", "relative", "both") { if inlist("`plot'", "csem", "both") { local plotcmds `plotcmds' /// (scatter `rel_y' `x' if `keep', msymbol(O) msize(small) /// mcolor(maroon)) } if "`smooth'" != "" { capture confirm variable `rel_y_sm' if !_rc { local plotcmds `plotcmds' /// (line `rel_y_sm' `x' if `keep', sort lcolor(maroon) lwidth(medthick)) } } } /* Legend logic. We track which positional index in the overlay corresponds to the absolute and relative "anchor" series (preferring scatter when present, then rarea, then line). Indices follow the order in which the (...) plots were added above. */ if "`absrel'" == "both" { local idx_abs 0 local idx_rel 0 local pos 0 if inlist("`plot'", "ci", "both") { local pos = `pos' + 1 local idx_abs = `pos' local pos = `pos' + 1 local idx_rel = `pos' } if inlist("`plot'", "csem", "both") { local pos = `pos' + 1 local idx_abs = `pos' if "`smooth'" != "" local pos = `pos' + 1 local pos = `pos' + 1 local idx_rel = `pos' } else if "`smooth'" != "" { /* plot(ci) with smooth: lines come right after both rareas */ local pos = `pos' + 1 local idx_abs = `pos' local pos = `pos' + 1 local idx_rel = `pos' } local legend legend(order(`idx_abs' "absolute" `idx_rel' "relative") rows(1)) } else { local legend legend(off) } } /* Add subtitle showing CI level when CI shown */ if ("`plot'" == "ci" | "`plot'" == "both") & "`compare'" == "" { if "`subtitle'" == "" { if "`cibands'" == "model" { local subtitle "`ci'% CI bands around quadratic fit" } else { local subtitle "`ci'% CI bands using `asemethod' SE" } } } /* ======================================================== Execute twoway ======================================================== */ local nameopt "" if "`name'" != "" local nameopt name(`name') local saveopt "" if "`saving'" != "" local saveopt saving(`saving') twoway `plotcmds', /// title("`title'") subtitle("`subtitle'") /// xtitle("`xtitle'") ytitle("`ytitle'") /// `legend' `nameopt' `saveopt' `options' end