*! fvgen Version 1.0.0 2026/10/15 *! Flatten factor-variable interactions into labeled main-effect and product variables *! Author: Timothy P Copeland, Karolinska Institutet *! Program class: rclass *! Requires: Stata 16.0+ /* Basic syntax: fvgen fvvarlist [if] [in] [weight] [, alllevels center prefix(name) /// ref(spec) simple(varname) vsref(string) replace xsymbol(string)] fvgen , margins [store(name) replace] fvgen , drop Materializes the terms of a factor-variable interaction specification as ordinary variables: indicator variables for categorical levels (base level dropped), and product variables for interactions. Value labels become variable labels so regression exports show one clean row per coefficient instead of the extra factor-variable header rows produced by the i.a##c.b approach. Generated variables carry provenance characteristics (fvgen_role, fvgen_term, fvgen_name) so downstream tools can recognize, group, and tear them down; fvgen, drop removes every fvgen-generated variable still under its generated name (a clonevar copy or a renamed variable is left in place). fvgen, margins rebuilds the active flattened estimation result with native factor-variable syntax so Stata's margins can read the original factor structure using the estimator's own postestimation metadata. See help fvgen for complete documentation. */ program define fvgen, rclass version 16.0 local _orig_varabbrev = c(varabbrev) set varabbrev off local _fvgen_est_held = 0 local _fvgen_unhold_rc = 0 local _fvgen_margins_active = 0 local _fvgen_keep_native = 0 local _fvgen_store_done "" tempname _fvgen_est_hold capture noisily { **# Peek: did the user type a leading fvvarlist? * An optional [varlist] defaults to every variable in the dataset when * omitted, so the parsed macro cannot tell "none typed" from "all". Detect * the leading token directly: a varlist, if present, precedes if/in/weight * and the option comma. local _peekline : copy local 0 gettoken _peek : _peekline, parse(" ,[") local _has_vl = 1 if inlist(`"`_peek'"', "", ",", "if", "in") local _has_vl = 0 if substr(`"`_peek'"', 1, 1) == "[" local _has_vl = 0 * Raw varlist text (everything before the option comma), kept before * syntax normalizes away the omit operator. local _rawvl : copy local 0 gettoken _rawvl _junk : _rawvl, parse(",") **# Syntax parsing syntax [varlist(fv)] [if] [in] [aweight pweight fweight iweight] [, /// ALLlevels /// CENTER /// PREfix(name) /// REF(string asis) /// SIMPle(varname) /// MARGINS /// STORe(name) /// VSref(string) /// replace /// XSYMbol(string) /// DROP] local _drop_extras `"`if'`in'`weight'`alllevels'`center'`prefix'`ref'`simple'`margins'`store'`vsref'`replace'`xsymbol'"' local _margins_extras `"`if'`in'`weight'`alllevels'`center'`prefix'`ref'`simple'`vsref'`xsymbol'`drop'"' if "`prefix'" == "" local prefix "_" if `"`xsymbol'"' == "" local xsymbol "×" local alllev = ("`alllevels'" != "") if !`_has_vl' local varlist "" if "`margins'" != "" | "`store'" != "" { **# Post-estimation mode: rebuild native-factor estimates for margins * This leaves the generated variables themselves untouched. The * optional store() path creates a margins-ready clone and restores * the active flattened estimates, keeping regtab output clean. if "`margins'" == "" { display as error "store() is only allowed with margins; use {cmd:fvgen, margins store(name)}" exit 198 } if `_has_vl' | `"`_margins_extras'"' != "" { display as error /// "margins does not take varlists, qualifiers, weights, or generation/drop options; use {cmd:fvgen, margins} or {cmd:fvgen, margins store(name)}" exit 198 } if "`replace'" != "" & "`store'" == "" { display as error "replace with margins is only allowed when store(name) is specified" exit 198 } * estimates store overwrites silently, so an existing stored * result must be protected here unless replace was given. if "`store'" != "" & "`replace'" == "" { quietly estimates dir local _stored_names `"`r(names)'"' if `: list store in _stored_names' { display as error /// "fvgen, margins: stored estimates '`store'' already exist; specify replace to overwrite them" exit 110 } } capture _estimates hold `_fvgen_est_hold', restore copy nullok if _rc { display as error "fvgen, margins: could not preserve the active estimates" exit _rc } local _fvgen_est_held = 1 * The refit replays the user's command line, so it runs under the * caller's varabbrev setting rather than fvgen's forced off. _fvgen_margins_repost `_orig_varabbrev' if "`store'" != "" { if "`replace'" != "" capture estimates drop `store' estimates store `store' return local stored "`store'" return local margins "stored" local _fvgen_store_done "`store'" } else { return local margins "active" local _fvgen_margins_active = 1 local _fvgen_keep_native = 1 } } else if "`drop'" != "" { **# Teardown mode: drop every fvgen-generated variable * Generated variables are tagged with a fvgen_role characteristic * and stamped with their own name in fvgen_name. clonevar copies * characteristics, so a variable is dropped only while its stamp * still equals its name: clonevar copies and renamed generated * variables are left in place. An empty stamp is a variable made * by an earlier pre-release build and keeps the old role-only rule. if `_has_vl' | `"`_drop_extras'"' != "" { display as error /// "drop does not take varlists, qualifiers, weights, or other options; use {cmd:fvgen, drop} alone to remove all fvgen-generated variables" exit 198 } local _dropped "" if `=c(k)' > 0 { foreach v of varlist * { local _role : char `v'[fvgen_role] local _stamp : char `v'[fvgen_name] if "`_role'" != "" & inlist("`_stamp'", "", "`v'") { drop `v' local _dropped "`_dropped' `v'" } } } mata: _fvgen_provclear() local _dropped : list clean _dropped local _nd : word count `_dropped' return local dropped "`_dropped'" return scalar k_dropped = `_nd' if `_nd' == 0 { display as text "fvgen: no fvgen-generated variables found to drop" } else { display as text "fvgen dropped " as result `_nd' /// as text " variable(s): " as result "`_dropped'" } } else { **# Require a specification if !`_has_vl' { display as error "fvvarlist required" exit 100 } local _fvgen_prevars "" if `=c(k)' > 0 unab _fvgen_prevars : _all **# Validate the vsref() template up front * vsref() appends the reference (base) level to main-effect labels. * The argument is a template in which @ stands for the base label. if `"`vsref'"' != "" & strpos(`"`vsref'"', "@") == 0 { display as error /// `"vsref() must contain @ as a placeholder for the reference label, e.g. vsref("(vs. @)")"' exit 198 } **# Weighting expression (used only by center; pweight is mapped to * aweight for the centering mean, which is numerically identical). local wmean "" if "`weight'" != "" { local _wt "`weight'" if "`_wt'" == "pweight" local _wt "aweight" local wmean "[`_wt'`exp']" } **# Reject string variables before marksample hides them * marksample marks every row out for a string variable, which would * otherwise surface as a misleading "no observations" error. quietly fvrevar `varlist', list local _basevars `r(varlist)' foreach _bv of local _basevars { capture confirm string variable `_bv' if !_rc { display as error /// "fvgen: '`_bv'' is a string variable; encode it to a numeric variable first" exit 109 } } **# ref()/simple() rewrite single-token factor operators only * A level-restricted factor i(numlist).VAR spans several tokens; * refuse it rather than misreport the variable as unused. if `"`ref'`simple'"' != "" & regexm("`varlist'", "(^| |#)i\(") { display as error /// "fvgen: ref() and simple() do not support level-restricted factors i(numlist).varname; use ib#.varname or restrict the sample with if/in" exit 198 } **# Mark sample marksample touse quietly count if `touse' if r(N) == 0 { display as error "no observations" exit 2000 } **# Reject the explicit omit operator (o.) * o. / Ndigit-o. / #o. mean "omit from estimation"; syntax silently * normalizes the o away, so fvgen would drop the term without * warning. Detect it in the raw varlist and tell the user how to * express the intent instead. The omit operator is digits-then-o * immediately before the dot, at the start of a term or after a space * or # (variable names never appear before the dot). if regexm(`"`_rawvl'"', "(^| |#)[0-9]*o\.") { display as error /// "fvgen does not support the omit operator (o.); restrict the sample with if/in or choose a base with ref()" exit 198 } **# Per-factor reference levels via ref() * Re-reference by rewriting each named factor's operator to ibLEVEL. * The normalized fv varlist always renders factors as i.VAR / ibN.VAR * (dotted, leading "i"); continuous terms are bare VAR or c.VAR. This * lets fvexpand handle base/empty-cell logic under the chosen bases * without touching dataset state (no fvset mutation). Levels may be * given as integer codes or as value-label strings, e.g. * ref(foreign "Domestic", rep78 3). local expandspec "`varlist'" if `"`ref'"' != "" { local refvars "" local reflevels "" local refwork `"`ref'"' while `"`refwork'"' != "" { gettoken rv refwork : refwork, parse(" ,") if `"`rv'"' == "," continue local _rl_quoted = 0 gettoken rl refwork : refwork, parse(" ,") qed(_rl_quoted) while `"`rl'"' == "," { gettoken rl refwork : refwork, parse(" ,") qed(_rl_quoted) } if `"`rv'"' == "" | `"`rl'"' == "" { display as error /// "ref() expects variable/level pairs, e.g. ref(sex 2, race 3)" exit 198 } capture confirm variable `rv' if _rc { display as error "ref(): variable '`rv'' not found" exit 111 } if `: list rv in refvars' { display as error "ref(): variable '`rv'' specified more than once" exit 198 } * Resolve the level: an integer code, else a value-label string. local _numeric_level = 0 if !`_rl_quoted' { capture confirm integer number `rl' if _rc == 0 local _numeric_level = 1 } if `_numeric_level' { local _lvl `rl' } else { local _vlbl : value label `rv' if "`_vlbl'" == "" { display as error /// "ref(): level '`rl'' for `rv' is not an integer and `rv' has no value label to resolve it" exit 198 } quietly levelsof `rv' if `touse', local(_obslv) local _lvl "" local _label_matches = 0 foreach L of local _obslv { local _thislab : label `_vlbl' `L' if `"`_thislab'"' == `"`rl'"' { local ++_label_matches local _lvl `L' } } if "`_lvl'" == "" { display as error /// `"ref(): label "`rl'" not found among observed levels of `rv'"' exit 198 } if `_label_matches' > 1 { display as error /// `"ref(): label "`rl'" identifies more than one observed level of `rv'; use an integer code"' exit 198 } } quietly levelsof `rv' if `touse', local(_reflv) if !`: list _lvl in _reflv' { display as error /// "ref(): `rv'==`_lvl' is not an observed level in the sample" exit 198 } local refvars "`refvars' `rv'" local reflevels "`reflevels' `_lvl'" } * Single rewrite pass over the normalized spec. local newspec "" local matched "" foreach term of local varlist { local rest "`term'" local newterm "" while `"`rest'"' != "" { gettoken comp rest : rest, parse("#") if "`comp'" == "#" { local newterm "`newterm'#" } else { local dotpos = strpos("`comp'", ".") if `dotpos' { local op = substr("`comp'", 1, `dotpos' - 1) local cv = substr("`comp'", `dotpos' + 1, .) local idx : list posof "`cv'" in refvars if `idx' & substr("`op'", 1, 1) == "i" { * ibn. asks for every level with no base; * ref() would silently impose one. if strpos("`op'", "bn") { display as error /// "ref(): '`cv'' is specified as ibn.`cv' (no base level); remove it from ref() or write i.`cv'" exit 198 } local rl : word `idx' of `reflevels' local comp "ib`rl'.`cv'" local matched "`matched' `cv'" } } local newterm "`newterm'`comp'" } } local newspec "`newspec' `newterm'" } local unmatched : list refvars - matched local unmatched : list uniq unmatched if "`unmatched'" != "" { display as error /// "ref(): variable(s) not used as a factor in the specification: `unmatched'" exit 198 } local expandspec : list clean newspec } **# Per-group simple effects via simple() * Report a continuous term's slope WITHIN each moderator level (combined * main + interaction) instead of a reference slope plus a difference. * Reparameterize to the nested form "i.mod i.mod#c.x": drop the * continuous main, keep the moderator main, and let fvexpand keep every * moderator level in the interaction (each becomes a standalone slope). local simplemod "" if "`simple'" != "" { local _modfactor 0 local absorbed "" foreach term of local expandspec { if strpos("`term'", "#") == 0 continue local rest "`term'" local has_mod 0 local other_cont "" local other_cat "" while `"`rest'"' != "" { gettoken comp rest : rest, parse("#") if "`comp'" == "#" continue local dotpos = strpos("`comp'", ".") if `dotpos' { local op = substr("`comp'", 1, `dotpos' - 1) local cv = substr("`comp'", `dotpos' + 1, .) } else { local op "" local cv "`comp'" } if "`cv'" == "`simple'" & substr("`op'", 1, 1) == "i" { local has_mod 1 } else if substr("`op'", 1, 1) == "i" { local other_cat "`other_cat' `cv'" } else { local other_cont "`other_cont' `cv'" } } if `has_mod' { local _modfactor 1 if "`other_cat'" != "" { display as error /// "simple() supports per-group slopes of continuous terms; '`simple'' interacts with a categorical term. Use margins or contrast for categorical-by-categorical simple effects." exit 198 } local absorbed "`absorbed' `other_cont'" } } if !`_modfactor' { display as error "simple(): '`simple'' is not a factor in the specification" exit 198 } local absorbed : list uniq absorbed if "`absorbed'" == "" { display as error "simple(): '`simple'' does not interact with any continuous term" exit 198 } * Drop the absorbed continuous main terms (bare tokens). local newspec "" foreach term of local expandspec { if strpos("`term'", "#") == 0 & strpos("`term'", ".") == 0 { if `: list term in absorbed' continue } local newspec "`newspec' `term'" } local expandspec : list clean newspec local simplemod "`simple'" } **# Expand the factor-variable specification on the marked sample * Empty/base cells are flagged omitted by _ms_parse_parts (r(omit)). fvexpand `expandspec' if `touse' local terms `r(varlist)' **# Plan pass: one parse per term * Reject higher-order interactions and record, per term, the source * variables, continuous terms (for center), factor base levels (for * vsref), and the generated name ("." when the term produces none: * a continuous main, an omitted base, or an empty cell). Base levels * and centered names live in parallel lists, not name-keyed macros, * which would overflow the 31-char local-name limit. local sourcevars "" local contvars "" local _vsbasevars "" local _vsbaselevels "" local _tnames "" local _k_out 0 foreach t of local terms { _ms_parse_parts `t' local newname "." if "`r(type)'" == "variable" { local sourcevars "`sourcevars' `r(name)'" local contvars "`contvars' `r(name)'" local ++_k_out } else if "`r(type)'" == "factor" { local sourcevars "`sourcevars' `r(name)'" if `"`vsref'"' != "" & r(base) { local _vsbasevars "`_vsbasevars' `r(name)'" local _vsbaselevels "`_vsbaselevels' `=r(level)'" } * Omitted levels are skipped, except the base under alllevels. if !r(omit) | (`alllev' & r(base)) { local newname "`prefix'`r(name)'_`=r(level)'" } } else if "`r(type)'" == "interaction" { if r(k_names) > 2 { display as error /// "fvgen supports up to 2-way interactions; '`t'' is higher-order" exit 198 } local sourcevars "`sourcevars' `r(name1)' `r(name2)'" if strpos("`r(op1)'", "c") local contvars "`contvars' `r(name1)'" if strpos("`r(op2)'", "c") local contvars "`contvars' `r(name2)'" if !r(omit) { local suff "" if !strpos("`r(op1)'", "c") local suff "`suff'_`=r(level1)'" if !strpos("`r(op2)'", "c") local suff "`suff'_`=r(level2)'" local newname "`prefix'`r(name1)'X`r(name2)'`suff'" } } local _tnames "`_tnames' `newname'" if "`newname'" != "." local ++_k_out } if !`_k_out' { display as error "fvgen: no variables to materialize from '`varlist''" exit 198 } local sourcevars : list uniq sourcevars local contvars : list uniq contvars * A centered copy is reused by both main effects and products so the * product is the product of centered terms. local cmapvars "" local cmapnames "" if "`center'" != "" { local cmapvars "`contvars'" foreach cv of local contvars { local cmapnames "`cmapnames' `prefix'`cv'_c" } } **# Preflight every generated name before changing the dataset * Reject over-long names, collisions with source variables, and * distinct terms mapping to one name; an existing unrelated variable * is allowed only under replace. Checking the full plan first means * a late failure cannot leave earlier generated variables behind. local plannednames "" foreach newname in `cmapnames' `_tnames' { if "`newname'" == "." continue if ustrlen("`newname'") > 32 { display as error /// "fvgen: generated name '`newname'' exceeds the 32-character limit; use a shorter prefix()" exit 198 } if `: list newname in sourcevars' { display as error /// "fvgen: generated name '`newname'' collides with a source variable; choose a different prefix()" exit 198 } if `: list newname in plannednames' { display as error /// "fvgen: distinct terms map to the same generated name '`newname''; rename a source variable or choose a different prefix()" exit 198 } capture confirm new variable `newname' if _rc & "`replace'" == "" { display as error "fvgen: variable '`newname'' already exists; specify the replace option" exit 110 } local plannednames "`plannednames' `newname'" } * Under center + replace, an existing centered copy is dropped * before the weighted centering mean is computed, so a weight that * reads that variable would be destroyed mid-run. if "`center'" != "" & "`replace'" != "" & `"`exp'"' != "" { foreach cname of local cmapnames { if ustrregexm(`"`exp'"', "(^|[^\p{L}\p{N}_])`cname'([^\p{L}\p{N}_]|$)") { display as error /// "fvgen: the weight expression uses '`cname'', which center would regenerate; rename it or choose a different prefix()" exit 198 } } } **# Invalidate the previous run's provenance * Only now that the plan is valid: a rejected call leaves an earlier, * still-valid flattened model usable by fvgen, margins. mata: _fvgen_provclear() **# Create centered copies local genvars "" foreach cv of local cmapvars { local _ci : list posof "`cv'" in cmapvars local cname : word `_ci' of `cmapnames' if "`replace'" != "" capture drop `cname' quietly summarize `cv' `wmean' if `touse', meanonly quietly generate double `cname' = `cv' - r(mean) local clab : variable label `cv' mata: st_local("_label_empty", strofreal(st_local("clab") == "")) if `_label_empty' local clab "`cv'" _fvgen_setlabel `cname' `"`macval(clab)' (centered)"' char `cname'[fvgen_role] "centered" char `cname'[fvgen_term] "c.`cv'" char `cname'[fvgen_name] "`cname'" local genvars `genvars' `cname' } **# Build pass: create the main-effect and interaction variables local mainvars "" local intvars "" local allvars "" local _ti 0 foreach t of local terms { local ++_ti local newname : word `_ti' of `_tnames' _ms_parse_parts `t' local ty "`r(type)'" if "`ty'" == "variable" { * Continuous main effect: the centered copy, or the original * passed through untouched. local v "`r(name)'" if "`center'" != "" { local _ci : list posof "`v'" in cmapvars local v : word `_ci' of `cmapnames' } local mainvars `mainvars' `v' local allvars `allvars' `v' } else if "`ty'" == "factor" { if "`newname'" == "." continue local v "`r(name)'" local L = r(level) local base = r(base) if "`replace'" != "" capture drop `newname' quietly generate double `newname' = (`v' == `L') if !missing(`v') _fvgen_partlabel `v' `L' mata: st_local("_flab", st_global("r(label)")) * vsref(): append the base level (skip the base row itself). if `"`vsref'"' != "" & !`base' { local _vsidx : list posof "`v'" in _vsbasevars if `_vsidx' { local _bl : word `_vsidx' of `_vsbaselevels' _fvgen_partlabel `v' `_bl' mata: st_local("_vstxt", subinstr(st_local("vsref"), "@", st_global("r(label)"))) mata: st_local("_flab", st_local("_flab") + " " + st_local("_vstxt")) } } _fvgen_setlabel `newname' `"`macval(_flab)'"' char `newname'[fvgen_role] "main" char `newname'[fvgen_term] "`t'" char `newname'[fvgen_name] "`newname'" local mainvars `mainvars' `newname' local genvars `genvars' `newname' local allvars `allvars' `newname' } else if "`ty'" == "interaction" { if "`newname'" == "." continue local n1 "`r(name1)'" local l1 = r(level1) local n2 "`r(name2)'" local l2 = r(level2) local f1 = (strpos("`r(op1)'", "c") == 0) local f2 = (strpos("`r(op2)'", "c") == 0) * Resolve the modeling expression and label for each side. forvalues s = 1/2 { if `f`s'' { local e`s' "(`n`s'' == `l`s'')" _fvgen_partlabel `n`s'' `l`s'' mata: st_local("lab`s'", st_global("r(label)")) } else { local e`s' "`n`s''" if "`center'" != "" { local _ci : list posof "`n`s''" in cmapvars local e`s' : word `_ci' of `cmapnames' } local lab`s' : variable label `n`s'' mata: st_local("_label_empty", strofreal(st_local("lab`s'") == "")) if `_label_empty' local lab`s' "`n`s''" } } if "`replace'" != "" capture drop `newname' quietly generate double `newname' = `e1' * `e2' if !missing(`n1', `n2') * Label priority: a continuous self-interaction reads "²" * ("^2" when xsymbol() is plain ASCII); * a simple() moderator interaction reads " ()"; * everything else joins the two sides with the x symbol. if "`n1'" == "`n2'" & `f1' == 0 & `f2' == 0 { mata: st_local("_ilab", st_local("lab1") + (ustrlen(st_local("xsymbol")) == strlen(st_local("xsymbol")) ? "^2" : "²")) } else if "`simplemod'" != "" & ("`n1'" == "`simplemod'" | "`n2'" == "`simplemod'") { if "`n1'" == "`simplemod'" mata: st_local("_ilab", st_local("lab2") + " (" + st_local("lab1") + ")") else mata: st_local("_ilab", st_local("lab1") + " (" + st_local("lab2") + ")") } else mata: st_local("_ilab", st_local("lab1") + " " + st_local("xsymbol") + " " + st_local("lab2")) _fvgen_setlabel `newname' `"`macval(_ilab)'"' char `newname'[fvgen_role] "interaction" char `newname'[fvgen_term] "`t'" char `newname'[fvgen_name] "`newname'" local intvars `intvars' `newname' local genvars `genvars' `newname' local allvars `allvars' `newname' } } **# Dataset-level provenance for post-estimation margins-ready clones * List-valued provenance is chunked: one characteristic holds at * most 67,784 bytes, which a wide dataset or a large factor design * exceeds (r(1004) after the variables were already created). local _sigvars : list _fvgen_prevars | genvars quietly _datasignature `_sigvars' local _fvgen_sig "`r(datasignature)'" foreach _c in spec terms allvars genvars centered sigvars signature { if "`_c'" == "spec" local _cval "`expandspec'" if "`_c'" == "terms" local _cval "`terms'" if "`_c'" == "allvars" local _cval "`allvars'" if "`_c'" == "genvars" local _cval "`genvars'" if "`_c'" == "centered" local _cval "`center'" if "`_c'" == "sigvars" local _cval "`_sigvars'" if "`_c'" == "signature" local _cval "`_fvgen_sig'" mata: _fvgen_charput("fvgen_`_c'", st_local("_cval")) } **# Return results return local spec "`expandspec'" return local allvars "`allvars'" return local mainvars "`mainvars'" return local intvars "`intvars'" return local genvars "`genvars'" return scalar k_all = `: word count `allvars'' return scalar k_main = `: word count `mainvars'' return scalar k_int = `: word count `intvars'' **# Display * Show the model varlist plus any generated helper (e.g. an absorbed * centered copy under center+simple) that is not itself a regressor. local _displayvars : list allvars | genvars display as text _n "fvgen created " as result `: word count `genvars'' /// as text " variable(s) from " as result "`expandspec'" as text ":" * Labels are shown literally (macval), and an unlabeled * pass-through variable shows its name, as label fallbacks do. foreach v of local _displayvars { local lb : variable label `v' mata: st_local("_label_empty", strofreal(st_local("lb") == "")) if `_label_empty' local lb "`v'" display as text " " as result %-28s "`v'" as text `" `macval(lb)'"' } } } local rc = _rc if `_fvgen_est_held' { if `rc' == 0 & `_fvgen_keep_native' { capture _estimates unhold `_fvgen_est_hold', not } else { capture _estimates unhold `_fvgen_est_hold' } local _fvgen_unhold_rc = _rc } set varabbrev `_orig_varabbrev' if `rc' exit `rc' if `_fvgen_unhold_rc' exit `_fvgen_unhold_rc' if `"`_fvgen_store_done'"' != "" { display as text "fvgen stored margins-ready estimates as " as result "`_fvgen_store_done'" /// as text "; the active flattened estimates were restored for table export" } if `_fvgen_margins_active' { display as text "fvgen rebuilt the active estimates with native factor-variable syntax for margins" } end **# Helper: build a friendly label for one factor level (returns r(label)) capture program drop _fvgen_partlabel program define _fvgen_partlabel, rclass version 16.0 local _orig_varabbrev = c(varabbrev) set varabbrev off capture noisily { args var level * A level absent from an attached value label falls back to var=level * like an unlabeled factor, instead of a bare, context-free number. local vl : value label `var' local lab "" if "`vl'" != "" { local lab : label `vl' `level', strict } mata: st_local("_label_empty", strofreal(st_local("lab") == "")) if `_label_empty' { local lab "`var'=`level'" } return local label `"`macval(lab)'"' } local rc = _rc set varabbrev `_orig_varabbrev' if `rc' exit `rc' end **# Helper: set a variable label, truncated to Stata's 80-character limit capture program drop _fvgen_setlabel program define _fvgen_setlabel, nclass version 16.0 local _orig_varabbrev = c(varabbrev) set varabbrev off capture noisily { gettoken name 0 : 0 gettoken lab 0 : 0 mata: st_local("_label_long", strofreal(ustrlen(st_local("lab")) > 80)) if `_label_long' { mata: st_local("lab", usubstr(st_local("lab"), 1, 80)) display as text /// "note: variable label for `name' truncated to Stata's 80-character limit" } mata: st_varlabel(st_local("name"), st_local("lab")) } local rc = _rc set varabbrev `_orig_varabbrev' if `rc' exit `rc' end **# Helper: rerun active flattened estimates with native factor-variable syntax capture program drop _fvgen_margins_repost program define _fvgen_margins_repost, eclass version 16.0 * args: the caller's varabbrev setting, restored for the native replay only args user_varabbrev if "`user_varabbrev'" == "" local user_varabbrev = c(varabbrev) local _orig_varabbrev = c(varabbrev) set varabbrev off capture noisily { if "`e(cmd)'" == "" { display as error "fvgen, margins requires active estimation results" exit 301 } capture confirm matrix e(b) if _rc { display as error "fvgen, margins requires active estimation results with e(b)" exit 301 } capture confirm matrix e(V) if _rc { display as error "fvgen, margins requires active estimation results with e(V)" exit 301 } mata: st_local("allvars", _fvgen_charget("fvgen_allvars")) mata: st_local("spec", _fvgen_charget("fvgen_spec")) if `"`allvars'"' == "" | `"`spec'"' == "" { display as error /// "fvgen, margins requires fvgen term provenance; rerun fvgen, estimate on r(allvars), then call fvgen, margins" exit 198 } mata: st_local("sigvars", _fvgen_charget("fvgen_sigvars")) mata: st_local("signature", _fvgen_charget("fvgen_signature")) if `"`sigvars'"' == "" | `"`signature'"' == "" { display as error /// "fvgen, margins requires current fvgen data provenance; rerun fvgen and the flattened estimator" exit 198 } capture confirm variable `sigvars' if _rc { display as error /// "fvgen, margins: data variables present when fvgen ran, or generated variables, changed afterward; rerun fvgen and the flattened estimator" exit 498 } quietly _datasignature `sigvars' if `"`r(datasignature)'"' != `"`signature'"' { display as error /// "fvgen, margins: data variables present when fvgen ran, or generated variables, changed afterward; rerun fvgen and the flattened estimator" exit 498 } mata: st_local("centered", _fvgen_charget("fvgen_centered")) if "`centered'" != "" { display as error /// "fvgen, margins does not support models generated with center; margins must see the same raw factor-variable scale used for estimation" exit 198 } local cmdline `"`e(cmdline)'"' if `"`cmdline'"' == "" { display as error "fvgen, margins requires e(cmdline) so the estimator can be rerun with native factor-variable syntax" exit 301 } * mi estimate posts only the per-imputation command in e(cmdline); * replaying it is not the combined MI fit, so refuse it by name. if `"`e(mi)'"' == "mi" { display as error /// "fvgen, margins cannot replay mi estimate results; e(cmdline) holds only the per-imputation command. Fit the model natively with factor-variable syntax" exit 198 } * The replay re-executes e(cmdline). A saving() option (any * abbreviation, prefix or vce() suboption, e.g. bootstrap, jackknife, * mi estimate, bayes) or another save*() option would rewrite the * user's file, so refuse rather than touch it. if ustrregexm(`"`cmdline'"', "(^|[\s,(])(sa|sav[A-Za-z0-9_]*)\s*\(", 1) { display as error /// "fvgen, margins: the estimation command line has a saving()-type option, and replaying it would rewrite that file" display as error /// "refit without saving() before calling fvgen, margins, or fit the model natively with factor-variable syntax" display as error `"`cmdline'"' exit 198 } local cmd_pad `" `cmdline' "' local varseq `" `allvars'"' local pos = strpos(`"`cmd_pad'"', `"`varseq'"') local nextchar "" if `pos' { local nextchar = substr(`"`cmd_pad'"', `pos' + strlen(`"`varseq'"'), 1) } if `pos' == 0 | !inlist(`"`nextchar'"', " ", ",") { display as error /// "fvgen, margins could not locate r(allvars) in the active estimation command line" display as error /// "rerun the model using the exact varlist returned by fvgen before calling fvgen, margins" exit 198 } local before = substr(`"`cmd_pad'"', 1, `pos' - 1) local after = substr(`"`cmd_pad'"', `pos' + strlen(`"`varseq'"'), .) local native_cmdline `"`before' `spec' `after'"' * Fingerprint the flattened fit. The flattened columns always lie in * the native design's span on a common sample, so an identical sample * plus identical rank (and log likelihood where posted) means the * native refit is the same model. A mismatch arises when fvgen ran on * a different if/in sample than the estimator (levels outside that * sample are lumped with the base), or with alllevels + noconstant. tempvar flat_sample quietly generate byte `flat_sample' = e(sample) quietly count if `flat_sample' local flat_has_sample = (r(N) > 0) local flat_N = e(N) local flat_rank = e(rank) local flat_ll = e(ll) quietly _ms_omit_info e(b) local flat_free = colsof(e(b)) - r(k_omit) * The variance is part of the fingerprint too: a resampling VCE * (bootstrap: prefix, vce(bootstrap)) redraws its replicates on * replay, so a matching sample and fit would still hide a different * V. Replaying from the fit's posted e(rngstate) redraws the same * replicates; the V check below catches anything that still differs. tempname flat_b flat_V flat_omit matrix `flat_b' = e(b) matrix `flat_V' = e(V) matrix `flat_omit' = r(omit) local flat_rngstate `"`e(rngstate)'"' local flat_resample = inlist("`e(vce)'", "bootstrap", "jackknife", "brr", "sdr") | /// inlist("`e(prefix)'", "bootstrap", "jackknife") * The caller's RNG state is restored after the replay, so the bridge * never moves the user's random-number stream. local user_rngstate = c(rngstate) if `"`flat_rngstate'"' != "" { capture set rngstate `flat_rngstate' if _rc { display as error /// "fvgen, margins: the fit's random-number state (e(rngstate)) does not match the current generator c(rng_current) = `c(rng_current)'; set rng to the generator used for the fit, or fit the model natively" exit 498 } } set varabbrev `user_varabbrev' * noisily keeps the estimator's own error message visible on failure; * quietly still suppresses its output on success. capture noisily quietly `native_cmdline' local native_rc = _rc set varabbrev off set rngstate `user_rngstate' if `native_rc' { display as error /// "fvgen, margins could not rerun the estimator with native factor-variable syntax" display as error `"`native_cmdline'"' exit `native_rc' } capture confirm scalar e(converged) if !_rc { if e(converged) == 0 { display as error /// "fvgen, margins: the estimator did not converge with native factor-variable syntax" display as error `"`native_cmdline'"' exit 430 } } local _mismatch "" if `flat_has_sample' { quietly count if `flat_sample' != e(sample) if r(N) > 0 local _mismatch "`_mismatch' estimation-sample" } if !missing(`flat_N') & !missing(e(N)) { if `flat_N' != e(N) local _mismatch "`_mismatch' N" } if !missing(`flat_rank') & !missing(e(rank)) { if `flat_rank' != e(rank) local _mismatch "`_mismatch' rank" } quietly _ms_omit_info e(b) if `flat_free' != colsof(e(b)) - r(k_omit) { local _mismatch "`_mismatch' free-coefficients" } if !missing(`flat_ll') & !missing(e(ll)) { if reldif(`flat_ll', e(ll)) > 1e-6 local _mismatch "`_mismatch' log-likelihood" } * Variance: compare the free (non-omitted, non-base) coefficients * position by position. When the free b vectors agree, both fits use * the same basis and V must agree too. A rank-deficient design may * pick a different omitted-column basis, so then V is not comparable * element-wise; that is accepted for a deterministic VCE (same model, * same V up to the basis) but refused for a resampling VCE, whose * replay cannot be verified. if "`_mismatch'" == "" { tempname nat_omit quietly _ms_omit_info e(b) matrix `nat_omit' = r(omit) capture confirm matrix e(V) if _rc { local _mismatch "`_mismatch' variance" } else { mata: _fvgen_vcheck("`flat_b'", "`flat_V'", "`flat_omit'", "`nat_omit'", `flat_resample') if `_fvgen_vbad' == 1 local _mismatch "`_mismatch' variance" if `_fvgen_vbad' == 2 { display as error /// "fvgen, margins: a resampling variance cannot be verified because the native refit chose a different omitted-column basis (exact collinearity, e.g. alllevels); fit the model natively" exit 498 } } } local _mismatch : list uniq _mismatch if "`_mismatch'" != "" { display as error /// "fvgen, margins: the native refit is not the same model as the active flattened estimates (differs in:`_mismatch')" display as error /// "this happens when fvgen ran on a different if/in sample than the estimator, or with alllevels and noconstant; a variance difference means a resampling VCE could not be replayed exactly; rerun fvgen on the estimation sample, or fit the model natively" display as error `"`native_cmdline'"' exit 498 } ereturn local fvgen_margins "1" ereturn local fvgen_flat_cmdline `"`cmdline'"' ereturn local fvgen_native_cmdline `"`native_cmdline'"' } local rc = _rc set varabbrev `_orig_varabbrev' if `rc' exit `rc' end **# Mata: variance fingerprint for the margins bridge (sets local _fvgen_vbad) * Free columns (omit flag 0) are compared in order. Aligned free b (mreldif * <= 1e-4) means one basis, so V must match: both are scaled by the flattened * standard errors (a scale-free comparison) and must agree to 1e-4, which is * far above ML convergence noise and far below any bootstrap redraw. * Unaligned free b is a different basis after exact collinearity: accepted * (0) for a deterministic VCE, unverifiable (2) for a resampling VCE. capture mata: mata drop _fvgen_vcheck() capture mata: mata drop _fvgen_provclear() capture mata: mata drop _fvgen_charput() capture mata: mata drop _fvgen_charget() mata: void _fvgen_vcheck(string scalar fb, string scalar fv, string scalar fo, string scalar no, real scalar resample) { real rowvector bf, bn, kf, kn real colvector sd real matrix Vf, Vn, S real scalar bad kf = selectindex(st_matrix(fo) :== 0) kn = selectindex(st_matrix(no) :== 0) bad = 1 if (cols(kf) == cols(kn) & cols(kf) > 0) { bf = st_matrix(fb)[kf] bn = st_matrix("e(b)")[kn] Vf = st_matrix(fv)[kf', kf] Vn = st_matrix("e(V)")[kn', kn] if (mreldif(bf, bn) <= 1e-4) { sd = sqrt(diagonal(Vf)) sd = sd + (sd :<= 0) S = sd * sd' bad = (mreldif(Vf :/ S, Vn :/ S) > 1e-4) } else bad = 2 * (resample != 0) } st_local("_fvgen_vbad", strofreal(bad)) } // Clear every _dta[fvgen_*] characteristic, chunk continuations included. void _fvgen_provclear() { string colvector nm real scalar i nm = st_dir("char", "_dta", "fvgen_*") for (i = 1; i <= rows(nm); i++) st_global("_dta[" + nm[i] + "]", "") } // Store a space-separated list in _dta[key], _dta[key_2], ... with each // chunk under the 67,784-byte characteristic limit, split between words. void _fvgen_charput(string scalar key, string scalar val) { string rowvector w string scalar chunk real scalar i, k w = tokens(val, " ") w = select(w, w :!= " ") chunk = "" k = 1 for (i = 1; i <= cols(w); i++) { if (chunk != "" & strlen(chunk) + 1 + strlen(w[i]) > 60000) { st_global("_dta[" + key + (k > 1 ? "_" + strofreal(k) : "") + "]", chunk) k++ chunk = "" } chunk = chunk + (chunk == "" ? "" : " ") + w[i] } st_global("_dta[" + key + (k > 1 ? "_" + strofreal(k) : "") + "]", chunk) } // Reassemble a list written by _fvgen_charput(). string scalar _fvgen_charget(string scalar key) { string scalar s, t real scalar k s = st_global("_dta[" + key + "]") k = 2 t = st_global("_dta[" + key + "_2]") while (t != "") { s = s + " " + t k++ t = st_global("_dta[" + key + "_" + strofreal(k) + "]") } return(s) } end