BRAGA

01 Work01-02

Decision analytics reconstruction

The 2018 Paraguay presidential election, reconstructed as three connected decision modules (voter segmentation, budget allocation and poll tracking) from what a campaign could see in early 2018.

Decision scienceFunctionalPublished 7 May 20266 min readRepository
+3.70 pp
Verified margin, TSJE
61.25%
Turnout, TSJE
50,000
Synthetic voters, 14 weeks
Contents
  1. 01Question
  2. 02Data
  3. 03Method
  4. 04Result
  5. 05Limitations
  6. 06Decision
  7. 07Build log
Summary

Three connected modules run end to end on 50,000 synthetic voters raked to the verified department-level anchors: on a USD 6 million envelope the allocator finds about 54.77% more linearised persuasion-proxy contacts than a department-uniform allocation, and the poll tracker’s 95% band caught the held-out poll in 3 of 6 tested waves. Each module is reported only as far as its own check reaches, so the gain stands as a solver comparison on synthetic reach, the tracking curve as a retrodiction, and nothing here claims that analytics caused the verified margin of 3.70 percentage points.

Question

Given only eight public polls, census aggregates and a fixed budget, how can a campaign track a national race, allocate money across the country, and stay honest about what it actually knows? The reconstruction works from what a campaign could see in early 2018 and reasons toward a result that is public: Mario Abdo Benítez won the presidential election by 3.70 percentage points (46.43% against 42.73%) on 61.25% turnout, according to the final results of the electoral tribunal (TSJE). The units of comparison are linearised persuasion-proxy contacts on a fixed budget, measured against a department-uniform allocation, and, for the tracker, held-out poll waves.

Data

The public anchors are verified: the TSJE returns, DGEEC census aggregates, the INE 2018 household survey on internet and WhatsApp use, and eight published tracking polls. Every individual voter is synthetic. A 50,000-record voter file is raked to the verified department-level anchors (turnout, gender, rural share, language), so no real person is represented while the aggregates still hold.

Inputs
Public anchors
TSJE final results · DGEEC census aggregates · INE 2018 household survey · eight published tracking polls
Voter file
50,000 synthetic records, raked to department-level turnout, gender, rural share and language
Window
14 weeks · seed 42
Module A
the synthetic voter file · a holdout of 15,000 voters
Module B
2,772 rows: 18 departments × 11 channels × 14 weeks · a USD 6 million envelope
Module C
the eight published polls · the verified outcome

Each artefact carries an epistemic tag (verified, calibrated, simulated or illustrative), and the register of those tags is part of the repository. The tag travels with the number.

Method

The three modules run as one pipeline, A to B to C, with an enforced handoff of the allocation between them:

  1. Module A, segmentation and turnout. Cluster the synthetic voters and score their turnout propensity.
  2. Module B, allocation. Spread the budget across departments, channels and weeks with a mixed-integer linear program.
  3. Module C, poll tracking. Fit a tracking curve to the eight polls, then run scenarios from it.

Each module is labelled with what it can and cannot claim.

Module A: segmentation and turnout

K-means with six segments on the synthetic population, plus a Platt-calibrated turnout propensity. The segments are held to two gates, the silhouette score and the bootstrap adjusted Rand index (ARI); the propensity is scored by its Brier score on a 15,000-voter holdout.

  • Can claim. Six segments that clear both gates, and a Platt-calibrated turnout propensity scored on 15,000 held-out synthetic voters.
  • Cannot claim. That the model generalises. It also reaches an area under the curve (AUC) of about 0.89, which is deliberately not headlined: the propensity features share calibration anchors with the target, so the AUC measures how well the raking was recovered, not how well the model generalises.

Module B: allocation

A mixed-integer linear program (MILP), written in PuLP and solved with CBC, over 2,772 rows: 18 departments × 11 channels × 14 weeks. It runs under a budget, reach caps, an exchange-rate layer and an 80% coverage floor, maximises a linearised persuasion proxy, and is served through a FastAPI application.

  • Can claim. A solver comparison on synthetic reach: how many linearised persuasion-proxy contacts one allocation produces against another.
  • Cannot claim. A historical effect of the 2018 campaign.

Module C: poll tracking

A Gaussian random walk with house effects (the systematic lean of each polling firm), fitted in PyMC to the eight polls, then stratified Monte Carlo scenarios, 10,000 draws by default.

Retrodiction

A fit that conditions on the known outcome. The tracking curve is one: it conditions on the verified result, so it cannot be read as a forecast.

  • Can claim. A retrodiction of the race through the eight polls.
  • Cannot claim. A forecast, or forecasting skill.

Result

Every headline number lives in one numeric source-of-truth table that continuous integration (CI) checks. The results follow module by module.

Segmentation

Both gates are cleared, and the turnout propensity reaches a Brier score of 0.1185 on the 15,000-voter holdout.

Measure Value Gate
Silhouette 0.2562 0.22
Bootstrap ARI 0.4304 0.40

Allocation

On a USD 6 million envelope, the MILP produces about 54.77% more linearised persuasion-proxy contacts than a department-uniform allocation. That is a solver comparison on synthetic reach, not a historical effect of the 2018 campaign.

Tracking

The one true out-of-sample check is reported unflattered. Refitting the tracker with each real poll wave held out, and the election result provably excluded, the 95% band caught the held-out poll in 3 of 6 tested waves, with a Brier score of 0.31 on the sign of the margin.

Fig. 01-02-1 · Out-of-sample check

The 95% band caught the held-out poll in 3 of 6 waves.

Tested6
Caught3
Count of real poll waves on a scale of 0–6, each wave held out in turn with the election result excluded; n = 6 tested waves.
Source: Module C refits, project repository.

Eight polls are far too few to claim forecasting skill, and the project does not claim it.

Result · Module B solver comparison, Module C refits

The three modules run end to end; the allocation gain of about 54.77% is a solver comparison on synthetic reach, and the tracker’s 95% band caught the held-out poll in 3 of 6 tested waves.

Limitations

What this does not establish:

  • A causal effect. A with-and-without-analytics counterfactual cannot be verified here.
  • Individual behaviour. Aggregate calibration of synthetic voters does not mean real behaviour was recovered.
  • Campaign economics. Module B optimises linearised contacts, not audited 2018 campaign accounting or fitted response curves.
  • Full scale. The pipeline runs 50,000 voters over 14 weeks; the full roll of 4.26 million voters is a design reference.

Decision

Report each module only as far as its own check reaches: the segments as a fit to synthetic voters, the allocation gain as a solver comparison on synthetic reach, and the tracking curve as a retrodiction. The reconstruction does not claim that analytics caused the 3.70-point margin. The public result is the anchor; showing what the model can and cannot support is the point of the work.

Functional: the three modules run end to end. It is a portfolio reconstruction, not a production campaign system. The repository holds the code, the case study and the validation gates.

Build log

339 commits and 75 merged pull requests on 34 days between 7 May 2026 and 27 Oct 2026. Merge commits and bots are left out; times are Vienna time.

Fig. 01-02-B1 · CalendarThe busiest day was 15 Jul 2026, with 46 commits.
MayJunJulAugSepOctMonWedFriSun7 May 2026 · 48 May 2026 · 19 May 2026 · 010 May 2026 · 111 May 2026 · 1512 May 2026 · 4413 May 2026 · 1514 May 2026 · 1315 May 2026 · 1016 May 2026 · 017 May 2026 · 018 May 2026 · 019 May 2026 · 020 May 2026 · 221 May 2026 · 022 May 2026 · 023 May 2026 · 024 May 2026 · 025 May 2026 · 026 May 2026 · 727 May 2026 · 028 May 2026 · 029 May 2026 · 030 May 2026 · 031 May 2026 · 01 Jun 2026 · 02 Jun 2026 · 03 Jun 2026 · 04 Jun 2026 · 05 Jun 2026 · 06 Jun 2026 · 07 Jun 2026 · 08 Jun 2026 · 49 Jun 2026 · 110 Jun 2026 · 011 Jun 2026 · 2412 Jun 2026 · 313 Jun 2026 · 2614 Jun 2026 · 1715 Jun 2026 · 416 Jun 2026 · 1117 Jun 2026 · 218 Jun 2026 · 019 Jun 2026 · 020 Jun 2026 · 021 Jun 2026 · 022 Jun 2026 · 023 Jun 2026 · 024 Jun 2026 · 025 Jun 2026 · 026 Jun 2026 · 027 Jun 2026 · 028 Jun 2026 · 029 Jun 2026 · 030 Jun 2026 · 01 Jul 2026 · 02 Jul 2026 · 03 Jul 2026 · 04 Jul 2026 · 05 Jul 2026 · 06 Jul 2026 · 07 Jul 2026 · 08 Jul 2026 · 09 Jul 2026 · 1110 Jul 2026 · 1211 Jul 2026 · 012 Jul 2026 · 013 Jul 2026 · 1114 Jul 2026 · 2015 Jul 2026 · 4616 Jul 2026 · 217 Jul 2026 · 118 Jul 2026 · 219 Jul 2026 · 320 Jul 2026 · 021 Jul 2026 · 022 Jul 2026 · 023 Jul 2026 · 024 Jul 2026 · 025 Jul 2026 · 026 Jul 2026 · 027 Jul 2026 · 028 Jul 2026 · 029 Jul 2026 · 030 Jul 2026 · 031 Jul 2026 · 01 Aug 2026 · 02 Aug 2026 · 03 Aug 2026 · 04 Aug 2026 · 05 Aug 2026 · 06 Aug 2026 · 07 Aug 2026 · 08 Aug 2026 · 09 Aug 2026 · 010 Aug 2026 · 011 Aug 2026 · 012 Aug 2026 · 013 Aug 2026 · 014 Aug 2026 · 015 Aug 2026 · 016 Aug 2026 · 017 Aug 2026 · 018 Aug 2026 · 019 Aug 2026 · 020 Aug 2026 · 021 Aug 2026 · 022 Aug 2026 · 023 Aug 2026 · 024 Aug 2026 · 025 Aug 2026 · 026 Aug 2026 · 027 Aug 2026 · 028 Aug 2026 · 029 Aug 2026 · 030 Aug 2026 · 031 Aug 2026 · 01 Sep 2026 · 02 Sep 2026 · 03 Sep 2026 · 04 Sep 2026 · 05 Sep 2026 · 06 Sep 2026 · 07 Sep 2026 · 08 Sep 2026 · 09 Sep 2026 · 010 Sep 2026 · 011 Sep 2026 · 012 Sep 2026 · 013 Sep 2026 · 014 Sep 2026 · 015 Sep 2026 · 016 Sep 2026 · 117 Sep 2026 · 418 Sep 2026 · 019 Sep 2026 · 020 Sep 2026 · 021 Sep 2026 · 122 Sep 2026 · 023 Sep 2026 · 024 Sep 2026 · 025 Sep 2026 · 026 Sep 2026 · 027 Sep 2026 · 028 Sep 2026 · 029 Sep 2026 · 030 Sep 2026 · 01 Oct 2026 · 02 Oct 2026 · 03 Oct 2026 · 04 Oct 2026 · 05 Oct 2026 · 06 Oct 2026 · 07 Oct 2026 · 08 Oct 2026 · 09 Oct 2026 · 010 Oct 2026 · 011 Oct 2026 · 1412 Oct 2026 · 013 Oct 2026 · 014 Oct 2026 · 015 Oct 2026 · 016 Oct 2026 · 017 Oct 2026 · 018 Oct 2026 · 019 Oct 2026 · 020 Oct 2026 · 021 Oct 2026 · 022 Oct 2026 · 023 Oct 2026 · 024 Oct 2026 · 025 Oct 2026 · 026 Oct 2026 · 327 Oct 2026 · 401–234–56–910–1516–2728–46

n = 339 commits. Colour: commits per day, seven bins from 1 to 46; an empty cell had none. Hover a cell for its count.
Source: git history of RafaelBraga-Kribitz/decision-analytics-reconstruction, default branch, snapshot 27 Oct 2026.

Fig. 01-02-B2 · WeekThe busiest hour of the week: Wednesday, 18:00–19:00, 23 commits.
0003060912151821MonTueWedThuFriSatSunMon 00:00 · 0Mon 01:00 · 0Mon 02:00 · 0Mon 03:00 · 0Mon 04:00 · 0Mon 05:00 · 0Mon 06:00 · 0Mon 07:00 · 0Mon 08:00 · 0Mon 09:00 · 0Mon 10:00 · 0Mon 11:00 · 1Mon 12:00 · 4Mon 13:00 · 1Mon 14:00 · 2Mon 15:00 · 4Mon 16:00 · 0Mon 17:00 · 1Mon 18:00 · 5Mon 19:00 · 8Mon 20:00 · 3Mon 21:00 · 3Mon 22:00 · 1Mon 23:00 · 5Tue 00:00 · 10Tue 01:00 · 10Tue 02:00 · 3Tue 03:00 · 3Tue 04:00 · 0Tue 05:00 · 0Tue 06:00 · 0Tue 07:00 · 0Tue 08:00 · 0Tue 09:00 · 1Tue 10:00 · 2Tue 11:00 · 1Tue 12:00 · 4Tue 13:00 · 2Tue 14:00 · 1Tue 15:00 · 5Tue 16:00 · 14Tue 17:00 · 5Tue 18:00 · 4Tue 19:00 · 2Tue 20:00 · 8Tue 21:00 · 5Tue 22:00 · 3Tue 23:00 · 4Wed 00:00 · 0Wed 01:00 · 0Wed 02:00 · 0Wed 03:00 · 0Wed 04:00 · 0Wed 05:00 · 0Wed 06:00 · 0Wed 07:00 · 1Wed 08:00 · 0Wed 09:00 · 1Wed 10:00 · 0Wed 11:00 · 3Wed 12:00 · 5Wed 13:00 · 7Wed 14:00 · 4Wed 15:00 · 0Wed 16:00 · 2Wed 17:00 · 7Wed 18:00 · 23Wed 19:00 · 13Wed 20:00 · 0Wed 21:00 · 0Wed 22:00 · 0Wed 23:00 · 0Thu 00:00 · 0Thu 01:00 · 0Thu 02:00 · 0Thu 03:00 · 0Thu 04:00 · 0Thu 05:00 · 0Thu 06:00 · 0Thu 07:00 · 0Thu 08:00 · 1Thu 09:00 · 5Thu 10:00 · 14Thu 11:00 · 9Thu 12:00 · 4Thu 13:00 · 0Thu 14:00 · 1Thu 15:00 · 2Thu 16:00 · 4Thu 17:00 · 8Thu 18:00 · 2Thu 19:00 · 3Thu 20:00 · 0Thu 21:00 · 0Thu 22:00 · 4Thu 23:00 · 1Fri 00:00 · 1Fri 01:00 · 3Fri 02:00 · 2Fri 03:00 · 0Fri 04:00 · 0Fri 05:00 · 0Fri 06:00 · 0Fri 07:00 · 0Fri 08:00 · 0Fri 09:00 · 2Fri 10:00 · 1Fri 11:00 · 1Fri 12:00 · 4Fri 13:00 · 0Fri 14:00 · 1Fri 15:00 · 0Fri 16:00 · 0Fri 17:00 · 2Fri 18:00 · 2Fri 19:00 · 1Fri 20:00 · 2Fri 21:00 · 0Fri 22:00 · 1Fri 23:00 · 4Sat 00:00 · 2Sat 01:00 · 3Sat 02:00 · 1Sat 03:00 · 6Sat 04:00 · 7Sat 05:00 · 0Sat 06:00 · 0Sat 07:00 · 0Sat 08:00 · 0Sat 09:00 · 0Sat 10:00 · 0Sat 11:00 · 0Sat 12:00 · 0Sat 13:00 · 1Sat 14:00 · 1Sat 15:00 · 0Sat 16:00 · 0Sat 17:00 · 0Sat 18:00 · 1Sat 19:00 · 4Sat 20:00 · 0Sat 21:00 · 0Sat 22:00 · 1Sat 23:00 · 1Sun 00:00 · 2Sun 01:00 · 0Sun 02:00 · 0Sun 03:00 · 0Sun 04:00 · 0Sun 05:00 · 0Sun 06:00 · 0Sun 07:00 · 2Sun 08:00 · 1Sun 09:00 · 0Sun 10:00 · 0Sun 11:00 · 0Sun 12:00 · 0Sun 13:00 · 0Sun 14:00 · 0Sun 15:00 · 7Sun 16:00 · 0Sun 17:00 · 3Sun 18:00 · 6Sun 19:00 · 2Sun 20:00 · 5Sun 21:00 · 5Sun 22:00 · 1Sun 23:00 · 101–2345–67–910–1516–23

n = 339 commits. Colour: commits per hour, seven bins from 1 to 23; an empty cell had none. Hover a cell for its count.
Source: git history of RafaelBraga-Kribitz/decision-analytics-reconstruction, default branch, snapshot 27 Oct 2026.

Fig. 01-02-B3 · Lines written

34 lines of tests for every 100 lines of code.

Code45,123
Tests15,251
Docs35,927
CI & setup2,686
Lines added, n = 98,987. Data, generated files, figures, notebooks and lockfiles are left out.
Source: git history of RafaelBraga-Kribitz/decision-analytics-reconstruction, default branch, snapshot 27 Oct 2026.

#election-campaigns#segmentation#optimisation#bayesian-tracking

Questions about this project, or a role where this kind of work fits?

Keyboard shortcuts

Single keys, ignored while typing in a field or with Cmd, Ctrl or Alt held.

0
Home
1 2 3 4
Work, Writing, Tools, About
j / k
Next / previous section on this page
n / p
Next / previous project or essay
u
Up one level
l
Deutsch
t
Light or dark theme
?
This list
Esc
Close