In [1]:
from __future__ import annotations

import os
import numpy as np
import polars as pl
from dotenv import load_dotenv


from survey_kit import logger
from survey_kit.statistics.adapters import stata_adapter, mi_ses_from_stata
from survey_kit.statistics import _stata_interop as _st
from survey_kit.statistics.replicates import Replicates
from survey_kit.utilities.random import set_seed, RandomNumberGenerator
from sample_data import make_implicates, with_bootstrap_weights

#   Machine-specific - set these in a local ".env" file (see .gitignore,
#   which excludes it from git) in the repo root rather than editing this
#   file or exporting them yourself:
#       _survey_kit_stata_path_=C:\Program Files\Stata17
#       _survey_kit_stata_edition_=se
#   Or, just as easily, set them directly in code instead of via env vars:
#       from survey_kit import config
#       config.stata_path = r"C:\Program Files\Stata17"
#       config.stata_edition = "se"
load_dotenv()
Out[1]:
True
In [2]:
logger.info("Before running any cell here, point survey_kit at your Stata install and")
logger.info("check what's importable:")
logger.info('    from survey_kit.statistics._stata_interop import check_stata_setup')
logger.info('    check_stata_setup(stata_path=r"C:\\Program Files\\Stata18")')
logger.info("")
logger.info("Requires `pip install survey-kit[stata]` (polars_readstat, for writing")
logger.info(".dta files) plus a licensed Stata 17+ install for pystata itself.")
Before running any cell here, point survey_kit at your Stata install and
check what's importable:
    from survey_kit.statistics._stata_interop import check_stata_setup
    check_stata_setup(stata_path=r"C:\Program Files\Stata18")

Requires `pip install survey-kit[stata]` (polars_readstat, for writing
.dta files) plus a licensed Stata 17+ install for pystata itself.
In [3]:
df_implicates = make_implicates()
In [4]:
logger.info("\n\nPart 1: mi_ses_from_stata - mi_ses_from_function(delegate=stata_adapter,")
logger.info("...), with stata_adapter's own arguments (command, edition, stata_path,")
logger.info("...) taken directly instead of packed into an arguments={} dict. Returns")
logger.info("the same shape every other adapter in survey_kit.statistics.adapters")
logger.info("does under the hood: (df_estimates, df_ses, df_vcov, df_tidy), combined")
logger.info("across implicates via Rubin's rules.")
logger.info("")
logger.info("Data goes into Stata as a .dta file written by polars_readstat, not")
logger.info("through pystata's own DataFrame transfer - this part IS verified (the")
logger.info(".dta round-trips correctly through polars_readstat's own reader), even")
logger.info("though the Stata-side execution below isn't.")

mi_reg = mi_ses_from_stata(
    df_implicates=df_implicates,
    command="regress y x1 x2",
    round_output=False,
)
mi_reg.print(round_output=False)

Part 1: mi_ses_from_stata - mi_ses_from_function(delegate=stata_adapter,
...), with stata_adapter's own arguments (command, edition, stata_path,
...) taken directly instead of packed into an arguments={} dict. Returns
the same shape every other adapter in survey_kit.statistics.adapters
does under the hood: (df_estimates, df_ses, df_vcov, df_tidy), combined
across implicates via Rubin's rules.

Data goes into Stata as a .dta file written by polars_readstat, not
through pystata's own DataFrame transfer - this part IS verified (the
.dta round-trips correctly through polars_readstat's own reader), even
though the Stata-side execution below isn't.
Implicate #1
Implicate #2
Implicate #3
Implicate #4
Implicate #5
┌──────────┬───────────┐
│ Variable ┆  estimate │
╞══════════╪═══════════╡
│       x1 ┆  1.996435 │
│          ┆  0.048527 │
│       x2 ┆ -1.488951 │
│          ┆  0.029976 │
│    _cons ┆  0.983256 │
│          ┆  0.028632 │
└──────────┴───────────┘
In [5]:
logger.info("\n\nWeighted regression, and a survey design set up per-implicate:")
logger.info("`command` can be a list of Stata commands run in order (run after")
logger.info("`use` but before the estimation command) instead of a single string -")
logger.info("only the LAST command's e(b)/e(V)/r(table) are read back.")

mi_svy = mi_ses_from_stata(
    df_implicates=df_implicates,
    #   svyset's pweight must be a variable, not a literal - replace
    #   with your actual design (real psu/weight/strata variables).
    command=[
        "gen _svy_weight = 1",
        "svyset _n [pw=_svy_weight]",
        "svy: regress y x1 x2",
    ],
    round_output=False,
)
mi_svy.print(round_output=False)

Weighted regression, and a survey design set up per-implicate:
`command` can be a list of Stata commands run in order (run after
`use` but before the estimation command) instead of a single string -
only the LAST command's e(b)/e(V)/r(table) are read back.
Implicate #1
Implicate #2
Implicate #3
Implicate #4
Implicate #5
┌──────────┬───────────┐
│ Variable ┆  estimate │
╞══════════╪═══════════╡
│       x1 ┆  1.996435 │
│          ┆   0.04782 │
│       x2 ┆ -1.488951 │
│          ┆  0.030583 │
│    _cons ┆  0.983256 │
│          ┆  0.028604 │
└──────────┴───────────┘
In [6]:
logger.info("\n\nReplicate-weight bootstrapping instead of Stata's own e(V): pass")
logger.info("replicates=, and `command` runs once per replicate weight column (via")
logger.info("stata_results_adapter under the hood, reading back e(b) only) rather")
logger.info("than Stata's own bootstrap/brr/jackknife prefix - the spread of")
logger.info("estimates across replicates IS the SE, computed in Python by")
logger.info("survey_kit's own Replicates/StatCalculator machinery. Stata's own")
logger.info("replicate-estimation commands are usually faster/more idiomatic if")
logger.info("you're already set up for them - this is for matching SEs computed the")
logger.info("same way elsewhere in a project instead. `command` needs a \"{weight}\"")
logger.info("placeholder here, unlike the e(V)-based calls above.")

N_REPLICATES = 20

mi_boot = mi_ses_from_stata(
    df_implicates=with_bootstrap_weights(df_implicates, n_replicates=N_REPLICATES),
    command="regress y x1 x2 [pw={weight}]",
    replicates=Replicates(weight_stub="replicate_", n_replicates=N_REPLICATES, bootstrap=True),
    round_output=False,
)
mi_boot.print(round_output=False)

Replicate-weight bootstrapping instead of Stata's own e(V): pass
replicates=, and `command` runs once per replicate weight column (via
stata_results_adapter under the hood, reading back e(b) only) rather
than Stata's own bootstrap/brr/jackknife prefix - the spread of
estimates across replicates IS the SE, computed in Python by
survey_kit's own Replicates/StatCalculator machinery. Stata's own
replicate-estimation commands are usually faster/more idiomatic if
you're already set up for them - this is for matching SEs computed the
same way elsewhere in a project instead. `command` needs a "{weight}"
placeholder here, unlike the e(V)-based calls above.
Implicate #1
Running stata_results_adapter
0....5....10....15....20
Implicate #2
Running stata_results_adapter
0....5....10....15....20
Implicate #3
Running stata_results_adapter
0...

.5....10....15....20
Implicate #4
Running stata_results_adapter
0....5....10....15....20
Implicate #5
Running stata_results_adapter
0....5.

...10....15....20
┌──────────┬───────────┐
│ Variable ┆  estimate │
╞══════════╪═══════════╡
│       x1 ┆  1.996435 │
│          ┆  0.062045 │
│       x2 ┆ -1.488951 │
│          ┆  0.053603 │
│    _cons ┆  0.983256 │
│          ┆  0.045043 │
└──────────┴───────────┘

In [7]:
logger.info("\n\nPart 2: calling this like any other delegate, standalone on one")
logger.info("dataset with no MI at all:")
(df_estimates, df_ses, df_vcov, df_tidy) = stata_adapter(
    df_implicates[0], command="regress y x1 x2",
)
logger.info(df_estimates)
logger.info(df_ses)
logger.info("df_tidy is Stata's own r(table), transposed to one row per term:")
logger.info(df_tidy)

Part 2: calling this like any other delegate, standalone on one
dataset with no MI at all:
shape: (3, 2)
┌──────────┬───────────┐
│ Variable ┆ estimate  │
│ ---      ┆ ---       │
│ str      ┆ f64       │
╞══════════╪═══════════╡
│ x1       ┆ 2.016691  │
│ x2       ┆ -1.478573 │
│ _cons    ┆ 0.990009  │
└──────────┴───────────┘
shape: (3, 2)
┌──────────┬──────────┐
│ Variable ┆ estimate │
│ ---      ┆ ---      │
│ str      ┆ f64      │
╞══════════╪══════════╡
│ x1       ┆ 0.021086 │
│ x2       ┆ 0.022008 │
│ _cons    ┆ 0.021457 │
└──────────┴──────────┘
df_tidy is Stata's own r(table), transposed to one row per term:
shape: (3, 10)
┌──────────┬───────────┬──────────┬────────────┬───┬───────────┬───────┬──────────┬───────┐
│ Variable ┆ b         ┆ se       ┆ t          ┆ … ┆ ul        ┆ df    ┆ crit     ┆ eform │
│ ---      ┆ ---       ┆ ---      ┆ ---        ┆   ┆ ---       ┆ ---   ┆ ---      ┆ ---   │
│ str      ┆ f64       ┆ f64      ┆ f64        ┆   ┆ f64       ┆ f64   ┆ f64      ┆ f64   │
╞══════════╪═══════════╪══════════╪════════════╪═══╪═══════════╪═══════╪══════════╪═══════╡
│ x1       ┆ 2.016691  ┆ 0.021086 ┆ 95.641636  ┆ … ┆ 2.058188  ┆ 297.0 ┆ 1.967984 ┆ 0.0   │
│ x2       ┆ -1.478573 ┆ 0.022008 ┆ -67.184034 ┆ … ┆ -1.435262 ┆ 297.0 ┆ 1.967984 ┆ 0.0   │
│ _cons    ┆ 0.990009  ┆ 0.021457 ┆ 46.140226  ┆ … ┆ 1.032235  ┆ 297.0 ┆ 1.967984 ┆ 0.0   │
└──────────┴───────────┴──────────┴────────────┴───┴───────────┴───────┴──────────┴───────┘
In [8]:
logger.info("\n\nPart 3: reaching a Stata command stata_adapter's generic `command=`")
logger.info("string already covers by itself - `stata_adapter` IS the 'arbitrary")
logger.info("estimator' entry point here (unlike the R side, there's no separate")
logger.info("named-wrapper-per-function layer to route around): any e-class command")
logger.info("works by just changing the `command` string, including svy: prefixes,")
logger.info("xtreg/xtlogit/areg, or a community-installed command (`ssc install`)")
logger.info("survey_kit has never heard of. Two examples:")

logger.info("\n  Fixed effects via areg:")
mi_areg = mi_ses_from_stata(
    df_implicates=[
        d.with_columns((pl.arange(0, pl.len()) % 10).alias("firm"))
        for d in df_implicates
    ],
    command="areg y x1 x2, absorb(firm)",
    round_output=False,
)
mi_areg.print(round_output=False)

logger.info("\n  Logit (any e-class command works the same way):")


def _with_ybin(d: pl.DataFrame, seed: int) -> pl.DataFrame:
    if seed > 0:
        set_seed(seed)
    rng = RandomNumberGenerator()
    p = 1 / (1 + np.exp(-d["x1"].to_numpy()))
    ybin = rng.binomial(1, p).astype(np.int8)
    return d.with_columns(pl.Series("ybin", ybin))


mi_logit = mi_ses_from_stata(
    df_implicates=[_with_ybin(d, seed) for seed, d in enumerate(df_implicates)],
    command="logit ybin x1 x2",
    round_output=False,
)
mi_logit.print(round_output=False)

Part 3: reaching a Stata command stata_adapter's generic `command=`
string already covers by itself - `stata_adapter` IS the 'arbitrary
estimator' entry point here (unlike the R side, there's no separate
named-wrapper-per-function layer to route around): any e-class command
works by just changing the `command` string, including svy: prefixes,
xtreg/xtlogit/areg, or a community-installed command (`ssc install`)
survey_kit has never heard of. Two examples:
  Fixed effects via areg:
Implicate #1
Implicate #2
Implicate #3
Implicate #4
Implicate #5
┌──────────┬───────────┐
│ Variable ┆  estimate │
╞══════════╪═══════════╡
│       x1 ┆  1.993556 │
│          ┆  0.052658 │
│       x2 ┆ -1.489476 │
│          ┆  0.028845 │
│    _cons ┆  0.982948 │
│          ┆  0.028747 │
└──────────┴───────────┘
  Logit (any e-class command works the same way):
Implicate #1
Implicate #2
Implicate #3
Implicate #4
Implicate #5
┌──────────┬──────────┐
│ Variable ┆ estimate │
╞══════════╪══════════╡
│       x1 ┆ 1.136263 │
│          ┆ 0.203196 │
│       x2 ┆ 0.067015 │
│          ┆ 0.216703 │
│    _cons ┆ 0.154841 │
│          ┆ 0.147616 │
└──────────┴──────────┘
In [9]:
logger.info("\n\nPart 4: if you need something below stata_adapter's command-string")
logger.info("level - a custom pre/post-processing step around the .dta write, or a")
logger.info("different way of pulling results back than e(b)/e(V)/r(table) - the")
logger.info("primitives in _stata_interop are the same ones stata_adapter itself is")
logger.info("built from, and you can call them directly:")
logger.info("  - dataframe_to_dta(df, path)        - write via polars_readstat")
logger.info("  - require_pystata(edition, stata_path) - get a live Stata session")
logger.info("  - run_stata_model(df, command, ...) - the full write+use+run+extract")
logger.info("                                         stata_adapter wraps")
logger.info("  - run_stata_results(df, command, results, ...) - like")
logger.info("                                         run_stata_model, but for ANY")
logger.info("                                         named r()/e() result rather")
logger.info("                                         than the fixed e(b)/e(V)/")
logger.info("                                         r(table) triplet")
logger.info("")
logger.info("A custom example - grabbing a scalar result that isn't part of")
logger.info("e(b)/e(V)/r(table) at all (say, e(N) or e(r2)):")

raw = _st.run_stata_results(
    df_implicates[0],
    command="regress y x1 x2",
    results=["e(N)", "e(r2)"],
)
logger.info(raw)

logger.info("")
logger.info("adapters.stata_results_adapter wraps run_stata_results the same way")
logger.info("stata_adapter wraps run_stata_model - it works for ANY command")
logger.info("(r-class or e-class) and any named result, not just an e-class fit's")
logger.info("coefficient table. It's shaped as a StatCalculator.from_function")
logger.info("delegate (point estimates only, no vcov - the SE comes from")
logger.info("resampling across replicate weights, not Stata's own e(V)) - it's what")
logger.info("mi_ses_from_stata uses under the hood for the replicates= case above.")

Part 4: if you need something below stata_adapter's command-string
level - a custom pre/post-processing step around the .dta write, or a
different way of pulling results back than e(b)/e(V)/r(table) - the
primitives in _stata_interop are the same ones stata_adapter itself is
built from, and you can call them directly:
  - dataframe_to_dta(df, path)        - write via polars_readstat
  - require_pystata(edition, stata_path) - get a live Stata session
  - run_stata_model(df, command, ...) - the full write+use+run+extract
                                         stata_adapter wraps
  - run_stata_results(df, command, results, ...) - like
                                         run_stata_model, but for ANY
                                         named r()/e() result rather
                                         than the fixed e(b)/e(V)/
                                         r(table) triplet

A custom example - grabbing a scalar result that isn't part of
e(b)/e(V)/r(table) at all (say, e(N) or e(r2)):
{'e(N)': 300.0, 'e(r2)': 0.9778995224472607}

adapters.stata_results_adapter wraps run_stata_results the same way
stata_adapter wraps run_stata_model - it works for ANY command
(r-class or e-class) and any named result, not just an e-class fit's
coefficient table. It's shaped as a StatCalculator.from_function
delegate (point estimates only, no vcov - the SE comes from
resampling across replicate weights, not Stata's own e(V)) - it's what
mi_ses_from_stata uses under the hood for the replicates= case above.
In [10]:
logger.info("\n\nSummary / troubleshooting checklist for getting this working:")
logger.info("  1. check_stata_setup(stata_path=...) - confirms pystata and")
logger.info("     polars_readstat both import; doesn't guarantee config.init()")
logger.info("     succeeds too.")
logger.info("  2. If a command errors immediately with just a bare 'r(####);' and no")
logger.info("     explanation, pass quietly=False to stata_adapter/run_stata_model/")
logger.info("     run_stata_results to see Stata's own error text in the console.")
logger.info("  3. If e(b)/e(V) come back empty, the command you ran probably isn't")
logger.info("     e-class (didn't leave results in e()) - `ereturn list` right after")
logger.info("     running it interactively in Stata will confirm.")
logger.info("  4. If r(table) shapes/names look different than expected, `matrix list")
logger.info("     r(table)` interactively will show you its real shape.")

Summary / troubleshooting checklist for getting this working:
  1. check_stata_setup(stata_path=...) - confirms pystata and
     polars_readstat both import; doesn't guarantee config.init()
     succeeds too.
  2. If a command errors immediately with just a bare 'r(####);' and no
     explanation, pass quietly=False to stata_adapter/run_stata_model/
     run_stata_results to see Stata's own error text in the console.
  3. If e(b)/e(V) come back empty, the command you ran probably isn't
     e-class (didn't leave results in e()) - `ereturn list` right after
     running it interactively in Stata will confirm.
  4. If r(table) shapes/names look different than expected, `matrix list
     r(table)` interactively will show you its real shape.