A general-purpose Python framework for simulating realistic survey response
data with Bayesian networks, following the design in
PROJECT.md: categorical and numeric answers, skip logic,
optional questions, statistical missingness, and dependencies among responses.
You declare your survey — questions, response types, the distributions you want to model with, and the skip logic — and the framework handles the rest: learning the network structure, fitting the conditional distributions, simulating synthetic respondents, and reporting how well it did.
Every question is modelled as a status → value pair of nodes, keeping three processes cleanly separate:
| process | node | how it is handled |
|---|---|---|
| deterministic eligibility | Q_status = not_applicable |
your skip rules (expert knowledge, never learned) |
| stochastic missingness | Q_status ∈ {answered, missing} |
a learned categorical CPD on the applicable rows |
| response value | Q_value |
a learned distribution, fit only on answered rows |
This separation makes the model interpretable, easy to constrain with expert knowledge, and well-suited to realistic survey simulation.
python3 -m pip install .For development, install it in editable mode with the test dependency:
python3 -m pip install -e ".[dev]"If you only want to run directly from a checkout without installing the package:
python3 -m pip install -r requirements.txtfrom surveysim import (Survey, Question, Continuous, Binary,
Count, BoundedCount, skip_if, SurveyModel)
survey = Survey([
Question("age", Continuous(lower=18, upper=95), missing=False),
Question("income", Continuous(family="lognormal"), missing=True),
Question("uses_tobacco", Binary(), missing=False),
# bounded count 0..30, only asked of tobacco users
Question("tobacco_days", BoundedCount(n=30, family="betabinomial"),
skip_rules=[skip_if(lambda r: r["uses_tobacco"] == "no",
depends_on=["uses_tobacco"])]),
# unbounded count with a separate zero process (hurdle), only for users
Question("cigs_per_day", Count(family="negbin", hurdle=True),
skip_rules=[skip_if(lambda r: r["uses_tobacco"] == "no",
depends_on=["uses_tobacco"])]),
])
model = SurveyModel(survey).fit(real_df) # learn structure + parameters
synthetic = model.simulate(10_000) # generate new respondents
print(model.describe()) # the learned network
print(model.validate(real_df, synthetic)) # how well did it do?A complete, runnable example (with a ground-truth data generator) lives in
examples/tobacco_survey.py:
python -m examples.tobacco_surveyFor a single guided tour of every feature — all response types, statistical
missingness, skip logic, expert knowledge and the fit/simulate/validate loop —
see examples/health_survey.py:
python -m examples.health_surveyThe framework is silent by default. Long runs can opt into progress output on the high-level methods:
model = SurveyModel(survey).fit(real_df, progress=True)
synthetic = model.simulate(10_000, progress=True)
report = model.validate(real_df, synthetic, progress=True)progress=True uses a tqdm progress bar in an interactive terminal when
tqdm is already installed, and falls back to periodic text logs otherwise.
Use progress="log" for deterministic text output in batch jobs, or
progress="bar" to prefer a bar. fit(..., verbose=True) still prints the
accepted hill-climb moves and now also enables fit progress.
Question(name, response, missing=True, skip_rules=[], text="")response— a response type describing the value distribution (below).missing— whether the answer can be statistically missing (modelled).skip_rules— deterministic eligibility rules producingnot_applicable.
A status node is created automatically whenever a question can be missing,
has skip rules, or declares special non-ordinal values; otherwise the
question is a single value node.
| type | use for | families |
|---|---|---|
Categorical(categories=...) |
nominal answers | multinomial logistic |
Binary() |
yes/no answers | logistic |
Ordinal(categories=..., special=...) |
ordered categorical answers | proportional-odds cumulative logit |
Continuous(family=..., lower=, upper=) |
continuous numbers | gaussian, lognormal, gamma |
Count(family=..., hurdle=) |
unbounded counts (e.g. cigarettes/day) | poisson, negbin (+ optional hurdle) |
BoundedCount(n=, family=...) |
bounded counts 0..n (e.g. days/month) | binomial, betabinomial |
Numeric answers are modelled with proper numeric likelihoods (never binned), and may depend on both categorical parents (indicator effects) and numeric parents (regression terms).
Use Ordinal for labelled response buckets with a known order, such as
frequency, agreement, severity, or other Likert-style answers:
Question(
"sunscreen_use_frequency",
Ordinal(
categories=["Never", "Rarely", "Sometimes", "Often", "Always"],
special=["Don't know"],
),
)categories is required and must list the substantive levels from lowest to
highest. The labels in the data must match those declared categories exactly,
except for missing values and any explicitly declared special values.
Unexpected observed labels raise a ValueError, which usually means the schema
is missing a level or the source data has a typo/inconsistent code.
Ordinal value nodes are fit with a proportional-odds cumulative-logit model: the model learns ordered thresholds plus shared parent effects, preserving the monotone structure of the scale while using fewer parameters than a nominal multinomial model.
special is for observed answers that should be reproduced but do not belong on
the ordinal scale, such as "Don't know" or "Unavailable/Unknown". These are
modelled on the question's status node as response dispositions, not as ordinal
values. That keeps the value node trained only on ordered categories; during
simulation, special labels can still appear in the output value column.
skip_if(lambda row: row["uses_tobacco"] == "no", depends_on=["uses_tobacco"])The predicate receives a respondent's answers and returns True when the
question is not applicable. depends_on tells the framework which inputs a
skip rule reads and keeps that deterministic skip relationship out of the
statistical structure search. At simulation time skip rules are re-applied, so
skipped answers are always blank — the validator confirms 0 violations.
Schema-implied constraints (status → value required, value → status
forbidden, skip dependencies excluded) are generated automatically. Add your
own:
from surveysim import ExpertKnowledge
ek = ExpertKnowledge().require("age", "income").forbid("income", "uses_tobacco")
model = SurveyModel(survey, knowledge=ek).fit(real_df)- Prepare — derive
not_applicable/missing/answeredstatus for every question from the data and skip rules (surveysim/prepare.py). - Constrain — build required/forbidden edges from the schema
(
surveysim/knowledge.py). - Score — a decomposable BIC-penalised local score; status nodes scored on
applicable rows, value nodes scored only on answered rows
(
surveysim/scoring.py,surveysim/models.py). - Search — constrained greedy hill-climbing over edge add/remove/reverse
(
surveysim/search.py). - Fit — final conditional distributions for the chosen parent sets.
- Simulate & validate — generate data respecting eligibility, missingness
and value distributions, then compare marginals, missingness, dependencies
and skip-logic consistency to the real data (
surveysim/network.py,surveysim/validation.py).
The same conditional-model objects are used for fast structure scoring and as the final CPDs, so a parent set that scores well is exactly what is sampled from. Richer final models can be plugged in per response type without touching the search.
model.validate(real, sim) reports, per question:
- marginals — KS statistic (numeric) or total-variation distance (categorical);
- missingness —
not_applicableandmissingrates, real vs simulated; - dependencies — pairwise correlations, real vs simulated;
- skip-logic consistency — count of rows where a skipped question still has a value (should be 0).
python tests/test_framework.py # or: pytest testssurveysim/
schema.py questions, response types, skip rules (user-facing)
models.py conditional distribution families (fit / score / sample)
prepare.py raw answers -> status/value nodes
knowledge.py required / forbidden edge constraints
scoring.py decomposable masked local score
search.py constrained hill-climbing
network.py fitted network + simulation
validation.py real-vs-simulated diagnostics
model.py SurveyModel high-level API
examples/tobacco_survey.py
examples/health_survey.py full feature tour
tests/test_framework.py