Skip to content

episphere/surveysim

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

surveysim

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.

The central idea

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.

Install

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.txt

Quick start

from 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_survey

For 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_survey

Progress reporting

The 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.

Defining a survey

Questions

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 producing not_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.

Response types

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).

Ordinal questions

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 logic

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.

Expert knowledge (optional)

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)

How it works

  1. Prepare — derive not_applicable / missing / answered status for every question from the data and skip rules (surveysim/prepare.py).
  2. Constrain — build required/forbidden edges from the schema (surveysim/knowledge.py).
  3. 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).
  4. Search — constrained greedy hill-climbing over edge add/remove/reverse (surveysim/search.py).
  5. Fit — final conditional distributions for the chosen parent sets.
  6. 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.

Validation output

model.validate(real, sim) reports, per question:

  • marginals — KS statistic (numeric) or total-variation distance (categorical);
  • missingnessnot_applicable and missing rates, 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).

Tests

python tests/test_framework.py      # or: pytest tests

Project layout

surveysim/
  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

About

A Python module for modelling and simulating survey data using Bayesian networks

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages