In one sentence: the model missed the margin by 1.2 points, vs. 4.6 for the polls it aggregated, and the result fell within the central interval; the part that helped most was the historical-error discount, and the part that hurt most was the house-effect correction anchored on the traditional pollsters.
previsoes_1turno_congeladas.json and not touched again. Below, each of its numbers against the TSE count: the bar is the range in which the model placed an 80% chance, the tick is the forecast median and the dot is the result (dark if it fell within the range, terracotta if it fell outside).| Eve-of-election question | What the model said | What happened | Prob. of what happened | Brier |
|---|---|---|---|---|
| Who finishes 1st? | Flávio, at 59% | Flávio, by 1.87 points | 59% | 0.171 |
| Will there be a runoff? | yes, at 98.7% | yes | 98.7% | 0.000 |
| Does Flávio make the runoff? | yes, at 100% | yes | 100% | 0.000 |
| Who finishes 3rd? | Cury, at 49% (the most likely) | Augusto Cury, with 2.89% | 49% | 0.263 |
| Candidate | Counted | Model (median) | Model error | Polls (undiscounted average) | Poll error | Who came closest |
|---|---|---|---|---|---|---|
| Lula (PT) | 45.16 | 45.0 | −0.2 | 45.5 | +0.3 | model |
| Flávio Bolsonaro (PL) | 47.03 | 45.7 | −1.3 | 42.8 | −4.2 | model |
| Augusto Cury (Avante) | 2.89 | 2.8 | −0.1 | 3.8 | +0.9 | model |
| Renan Santos (Missão) | 2.24 | 2.4 | +0.2 | 3.2 | +1.0 | model |
| Ronaldo Caiado (PSD) | 2.18 | 2.3 | +0.1 | 3.0 | +0.8 | model |
| Romeu Zema (Novo) | 0.27 | 0.4 | +0.1 | 0.7 | +0.4 | model |
| Others (6 candidates) | 0.22 | 0.7 | +0.5 | 1.1 | +0.9 | model |
| Mean absolute error | 0.35 | 1.22 | model |
Error = forecast minus counted, in points of valid votes. Brier = (1 − probability assigned to what happened)²; 0 is a correct call with total certainty and 0.25 is what you get by guessing 50% on a yes-or-no question; for the third-place question, with four names, a uniform guess (25% each) would give 0.563. A single election is not enough to say whether the model's uncertainty is calibrated: the numbers above say whether it was useful this time, not whether it will be right next time.
| Forecast | Forecast margin | Error | P(Lula 1st) | Brier | Log loss |
|---|---|---|---|---|---|
| Undiscounted polls (model aggregate) | +2.7 | +4.6 | 91% | 0.834 | 2.44 |
| Simple average of the latest polls | +1.6 | +3.5 | — | — | — |
| Published model (discount +3.4, 60% of the historical) | −0.7 | +1.2 | 41% | 0.171 | 0.53 |
| Full historical discount (+5.7) | −3.0 | −1.1 | 22% | 0.048 | 0.25 |
Brier = (probability assigned to Lula leading − 0)²; log loss = −ln(probability assigned to the actual outcome). Lower is better on both. Actual margin: −1.87.
| Component | Assessment | Evidence |
|---|---|---|
| Discount for the historical first-round error | worked | Moved the margin from +2.7 to −0.7, in the right direction, and eliminated 74% of the polling error. |
| Size of the discount (60% of the historical average) | conservative | The anchor's actual error (+4.7) fell between the discount used (+3.4) and the full historical figure (+5.7). With the full historical figure, the median would have been −3.0 and the Brier score lower. |
| Width of the uncertainty | adequate | The result landed at the 37th percentile, inside the 50% interval; 6 of the 7 candidates fell within the 80% band. |
| House effects anchored on the traditional pollsters | failed | The correction pushed 11 of the last 14 polls away from the result: it penalized the pollsters that got it right (Palver, Gerp, Futura) for diverging from the anchor, and the anchor missed as a bloc. The bias discount offset the level, but the information from those pollsters was thrown away. |
| Lula's level | got it right | Median 45.0 vs. 45.16 counted. |
| Flávio's level and the collapse of the third way | underestimated | Median 45.7 vs. 47.03: the simulated collapse only redistributed votes without shifting the margin, and the actual collapse was larger and went entirely to Flávio. |
| 3rd place and a first-round win | got it right | Cury in 3rd was the most likely outcome (49%); the chance of the race being settled in the first round was 1.3%. |
| State-level reading | failed in the Southeast | State polls projected a Lula gain in the Southeast, and instead he lost 4.3 points; the layer carried 6% of the level and was dropped from the model in the post-first-round regime. |
| Rolling-origin validation | could not detect the problem | 85% coverage of the 80% interval, but measured against the polling average itself: an error shared by every pollster is invisible to it by construction. |
| Post-first-round regime (runoff) | failure fixed today | With the result in hand and no new polls at all, the discount on runoff polls fell from +3.4 to +2.0 and Lula's chance rose from 28% to 37%. Waves fielded before Oct 4 now receive the observed error (+4.7); with that, Lula's chance is 17%, and polls and counted votes converge. |
| Run | Version | Lula 1st | Flávio 1st |
|---|---|---|---|
| Sep 30 | v33 | 58% | 42% |
| Oct 1, afternoon | v36 | 50% | 50% |
| Oct 1, evening | v38 | 52% | 48% |
| Oct 2 | v39 | 46% | 54% |
| Oct 3, morning | v40 | 44% | 56% |
| Oct 3, afternoon (audit) | v41 | 56% | 44% |
| Oct 3, afternoon (MDA, Palver) | v42 | 38% | 62% |
| Oct 3, evening (2nd audit) | v43 | 45% | 55% |
| Oct 3, evening · frozen | v44 | 41% | 59% |
Lula's chance swung between 38% and 58% with the polls and method corrections of the final week, never giving Flávio less than 42%. The noise between runs was as large as the effect of the audits: a sign that the forecast was, correctly, close to 50%.
(1) Runoff polls fielded before Oct 4 receive the anchor's observed first-round error (+4.7), and those fielded afterward receive the discount from the final runoff rounds plus 25% of that error; the mix follows the weight the new polls gain in the filter. (2) Counted votes enter the margin with the polls' total error, not just the sampling error, and currently weigh 21%. (3) Each pollster's first-round error, measured against the anchor, becomes the prior for its runoff house effect. (4) The market again weighs 25%, with prices from after the result. Pending: revisit the anchor and the shrinkage of the discount with the updated first-round series (2010–2026: +5.2; +10.5; +2.4; +5.8; +4.7).
In one sentence: the model simulates the election 400,000 times from today's trend, adding the uncertainty about what can still change through Oct 25 (drift) and the historical polling error.
| Quantity | Main (Monte Carlo) | MCMC (full posterior) |
|---|---|---|
| Lula's chance · polls only | 17% | 23% |
| Lula's chance · ensemble | 18% | 23% |
| Median margin on Oct 25 | -2.0 | -1.4 |
| 80% range of the final margin | -4.8 to +0.7 | -3.7 to +1.1 |
| True margin today (net of polling error) | -2.0 ± 4.5 | -1.3 ± 3.8 |
| Drift through Oct 25 (SD) | 0.0 | 0.0 |
| σrw: daily change until the first round (pp) | 0.31 (fixed) | 0.31 (80%: 0.22 to 0.43) |
| σbetween: daily change between rounds (pp) | 0.95 (fixed) | 0.63 (80%: 0.39 to 0.95) |
| Common polling error β, beyond the historical discount | drawn: t5(0; 2.5) | -0.6 (80%: -2.7 to 1.5) |
| Convergence | — | max R̂ 1.014 · min ESS 589 |
Difference in Lula's polls-only chance: +6.7 pp (MCMC minus main). Today's true margin differs by +0.7 pp between the two models. With new polls, the two readings tend to converge.
| Step | Lula · polls only | Flávio · polls only | Effect of the step |
|---|---|---|---|
| Previous model (v2): anchor on all pollsters, bias +2.1, drift 4.3 | 21.6% | 78.4% | |
| + anchor on calibrated pollsters (house-effect identification) | 30.4% | 69.6% | +8.8 pp for Lula |
| + recalibrated bias (+0.9 ± 2.0) | 38.9% | 61.1% | +8.5 pp for Lula |
| + calibrated two-phase drift (0.0) | 24.8% | 75.2% | -14.1 pp for Lula |
| + P(Flávio in the runoff) from the first-round model (1.00) | 15.7% | 84.3% | -9.1 pp for Lula |
| + bias of runoff polls fielded before the first round (+0.8 ± 1.98) | 16.5% | 83.5% | +0.8 pp for Lula |
| Adjusted polling margin in the last week (Lula − Flávio, pp of valid votes) | -2 | -1 | 0 | +1 | +2 | +3 | +4 | +5 | +6 | +7 | +8 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Lula's chance | 10% | 20% | 36% | 54% | 72% | 85% | 94% | 98% | 99% | 100% | 100% |
| Flávio's chance | 90% | 80% | 64% | 46% | 28% | 15% | 6% | 2% | 1% | 0% | 0% |
Break-even point (50% each): a +0.8 Lula margin in the final polls, which equals the historical error being discounted. Lula reaches 70% at +1.9 and 90% at +3.5; Flávio reaches the same 70% and 90% at margins of -0.3 and -1.9 (symmetric around break-even). Today the adjusted margin is -1.3.
In one sentence: each pollster's latest poll, the weight it gets in the calculation and the typical deviation (house effect) that the dashboard corrects.
| Pollster | Sponsor | Fieldwork | n | Method | Margin of error (95%) | Lula × Flávio | Valid-vote margin | House effect | Adjusted margin | Extra variance and weight | MAE 2006–22 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| Futura (education from PNAD) · 21 waves | Apex/100% Cidades | 24/10 | 2,000 | phone | ±2.2 pp | 47.9 × 48.2 | -0.3 | -0.4 | +0.1 | σp = 0.8 · weight 0.55 | 2.4 |
| PoderData · 17 waves | Aya | 24/10 | 4,000 | phone | ±1.5 pp | 47.0 × 49.0 | -2.1 | -1.2 | -0.9 | σp = 0.8 · weight 1.00 · anchor | 4.5 |
| BTG/Nexus · 19 waves | BTG Pactual | 24/10 | 2,000 | phone | ±2.2 pp | 46.0 × 47.0 | -1.1 | -0.6 | -0.5 | σp = 0.8 · weight 0.54 | — |
| Quaest · 19 waves | Globo/O Globo | 24/10 | 3,702 | in-person | ±1.6 pp | 45.0 × 46.0 | -1.1 | -0.5 | -0.6 | σp = 0.8 · weight 0.90 · anchor | 4.0 |
| Datafolha · 18 waves | Folha/Globo | 24/10 | 4,050 | in-person | ±1.5 pp | 47.0 × 48.0 | -1.1 | 0.0 | -1.1 | σp = 0.8 · weight 1.00 · anchor | 3.4 |
| Palver (v3 calibration) · 9 waves | self-funded | 24/10 | 5,000 | online | ±2.3 pp | 49.0 × 50.0 | -1.0 | -0.3 | -0.7 | σp = 0.8 · weight 0.51 | — |
| Gerp · 20 waves | self-funded | 24/10 | 2,400 | phone | ±2.0 pp | 46.0 × 49.0 | -3.2 | -3.8 | +0.7 | σp = 0.8 · weight 0.64 | — |
| Instituto França · 3 waves | self-funded | 23/10 | 1,537 | not reported | ±2.5 pp | 43.1 × 45.6 | -2.8 | -0.2 | -2.6 | σp = 0.8 · weight 0.40 | — |
| Colectta · 1 wave | Brazil Economy | 23/10 | 3,000 | in-person | ±1.8 pp | 46.5 × 46.0 | +0.5 | +0.2 | +0.3 | σp = 0.8 · weight 0.76 | — |
| CNT/MDA (weighted by the first round) · 7 waves | CNT | 23/10 | 2,032 | in-person | ±2.2 pp | 45.0 × 46.3 | -1.4 | -0.3 | -1.1 | σp = 0.8 · weight 0.54 | 3.9 |
| Veritá · 9 waves | self-funded | 23/10 | 15,554 | phone | ±1.4 pp | 46.5 × 49.4 | -3.1 | -2.7 | -0.4 | σp = 1.3 · weight 0.83 | 3.8 |
| AtlasIntel · 17 waves | Bloomberg | 23/10 | 4,990 | online | ±2.0 pp | 48.4 × 49.9 | -1.5 | -0.2 | -1.3 | σp = 0.8 · weight 0.69 · anchor | 4.5 |
| Ipespe · 3 waves | Abrapel | 22/10 | 1,500 | phone | ±2.5 pp | 47.0 × 48.0 | -1.1 | -0.9 | -0.2 | σp = 0.8 · weight 0.42 · anchor | 4.9 |
| Alfa Inteligência · 6 waves | self-funded | 22/10 | 2,700 | in-person | ±1.9 pp | 46.0 × 49.9 | -4.1 | +0.1 | -4.2 | σp = 1.0 · weight 0.65 | — |
| Real Time Big Data · 9 waves | self-funded | 22/10 | 2,000 | phone | ±2.2 pp | 45.0 × 49.0 | -4.3 | -0.8 | -3.5 | σp = 0.8 · weight 0.54 | — |
| Vox Brasil · 11 waves | self-funded | 22/10 | 2,100 | in-person | ±2.1 pp | 45.4 × 44.3 | +1.2 | -0.7 | +1.9 | σp = 1.4 · weight 0.45 | — |
| Indexa · 8 waves | Broadcast/Estadão | 21/10 | 2,000 | phone | ±2.2 pp | 44.1 × 45.2 | -1.2 | +0.5 | -1.8 | σp = 0.8 · weight 0.52 | — |
| DataTrends · 5 waves | self-funded | 21/10 | 2,000 | phone | ±2.2 pp | 46.0 × 50.0 | -4.2 | -0.9 | -3.2 | σp = 0.8 · weight 0.55 | — |
| Meio/Ideia · 11 waves | Canal Meio | 19/10 | 1,500 | phone | ±2.5 pp | 46.0 × 48.0 | -2.1 | -0.4 | -1.7 | σp = 0.8 · weight 0.42 | — |
| American Analytics · 2 waves | Times Brasil/CNBC | 20/09 | 2,000 | not reported | ±2.2 pp | 43.0 × 43.0 | 0.0 | 0.0 | 0.0 | σp = 0.8 · weight 0.50 | — |
| Vetor/Arrow · 2 waves | self-funded | 18/05 | 9,000 | not reported | ±1.4 pp | 50.7 × 38.3 | +13.9 | -0.4 | +14.3 | σp = 6.0 · weight 0.08 | — |
| Paraná Pesquisas · 4 waves | self-funded | 28/03 | 2,080 | in-person | ±2.1 pp | 44.1 × 45.2 | -1.2 | -2.0 | +0.8 | σp = 0.8 · weight 0.54 · anchor | 3.8 |
In one sentence: a single statistical model simultaneously estimates the trend, each pollster's deviation and how much each one errs beyond its stated margin; the trend is anchored on pollsters with a verified track record, and that same set's historical error is discounted in the simulation.
This is the core of the benchmark aggregators (Jackman 2005; Linzer 2013; Heidemanns, Gelman and Morris for The Economist): the true margin xt moves a little each day and each poll is a noisy measurement of it, shifted by the pollster's house effect hp. The three blocks are estimated jointly, by iteration: a Kalman smoother for xt, a shrunken posterior mean for hp and, for σp, the variance left in each pollster's residuals beyond sampling error, shrunk toward the median. Erratic pollsters (Veritá, Vetor, Gerp, CNT/MDA) get a high σp and weigh less, with no arbitrary weights. σrw = 0.31 pp/day in the campaign window (since Jun 1, 165 polls), estimated with a prior centered on 0.25 (without the prior: 0.32).
A house effect is a deviation from some reference, and the choice of reference sets the level of the whole curve. The previous version used the average of all pollsters, weighted by number of waves, which left the "consensus" in the hands of whoever publishes most (Gerp, Futura, Palver and Veritá, the biggest outliers toward Flávio). Now the reference is the anchor: AtlasIntel, Datafolha, Ipespe, CNT/MDA, Paraná Pesquisas, PoderData, Quaest, the pollsters with a verified presidential track record in 2014–2022, weighted by historical quality. Only their average is fixed at zero; each one can deviate. The reason is consistency: the historical error discounted in step 3 was measured on these same pollsters, so level and discount refer to the same thing. Switching the anchor shifts today's level by +1.7 pp (see "what changed").
Each recent state poll becomes a swing relative to the state's 2022 runoff, corrected for the pollster's national house effect and for movement in the margin since the end of fieldwork. Two-level shrinkage, state → region → country (DerSimonian-Laird, τ = 7.2); the electorate-weighted sum gives a second reading of the national margin (+3.6), combined with the national one assuming a 0.3 correlation between the errors (current weight: 0%), provided the states with polls add up to at least 30% of the electorate.
For each of the last 60 days, the model was refit using only polls up to that day and predicted the polls of the following 7 days. Across 233 forecasts: the 80% interval covered 85% of cases and the 95% interval covered 94% (adequate calibration); mean absolute error of 1.9 pp; mean bias of -0.3 pp (polls came in, on average, more favorable to Flávio than predicted a week earlier, reflecting the August–September slide, which a random walk does not extrapolate). Broken down: -0.3 pp for pollsters the fit already knew (217 forecasts) and -0.8 pp for those that joined the database later (16), which debut with a zero house effect. This bias is information and is not corrected: extrapolating the trend was the flaw of the previous version.
Where the movement comes from. How much today's margin would change if each poll from the last 7 days were removed (positive = the poll pulls toward Lula). In a series that ends today, the most recent poll weighs the most.
| Poll | Fieldwork through | Effect on the runoff margin | Effect on the first-round margin |
|---|---|---|---|
| Alfa Inteligência | 22/10 | -0.09 | — |
| Gerp | 24/10 | +0.09 | — |
| PoderData | 21/10 | +0.06 | — |
| Vox Brasil | 22/10 | +0.06 | — |
| Real Time Big Data | 22/10 | -0.06 | — |
| Colectta | 23/10 | +0.06 | — |
| Veritá | 22/10 | +0.05 | — |
| DataTrends | 21/10 | -0.05 | — |
In the five runoffs from 2006 to 2022, the national pollsters' final polls missed the PT − right margin by +0.3, -0.1, +1.1, -1.2 and +2.9 points (table below; positive = overestimated the PT). The recency-weighted average is +0.9; the simple average, +0.6. The previous version used +2.1, with 2.5× weight on the 2022 cycle. The deviation between cycles (σ = 2.0) enters as uncertainty. After the first round. The anchor's polls missed the first-round Lula − Flávio margin by +4.7 pp (positive = overestimated Lula). Runoff waves fielded before Oct 4 are the same ones that carried that error and receive the observed discount (+4.7); those fielded after receive only the discount from the final runoff rounds (+0.9). Until Oct 22 they also received 25% of the first-round error; the Oct 22 revision (v56) removed that inheritance because the first-round error had the signature of tactical voting: the final polling average missed Lula by -0.3 pp and Flávio by +4.2, while the other candidates lost 4.0 points, nearly all of which went to Flávio, and that mechanism does not exist in a two-candidate runoff. The runoff's own error remains in the discount: in the final rounds from 2006 to 2022 it was +0.3 (2006), -0.1 (2010), +1.1 (2014), -1.2 (2018), +2.9 (2022), with more weight on recent cycles. The inheritance took about 8 points off Lula's chance in the main model (12.8% vs. 20.6% with the Oct 22 data). Today 57 post-first-round waves account for 100% of the polling margin. The official result, with the vote transfer from the other candidates, gives a starting margin of -2.7 ± 2.5 on Oct 4, which becomes ± 5.0 today once the electorate's drift since then is added; the polls give -0.9 ± 0.7 before the discount. The dashboard combines the two by each one's total error (for the polls, sampling plus systematic): today the official result weighs 21% and loses weight every day, because the Oct 4 snapshot ages as new polls arrive. (Oct 4 revision: the previous rule applied the new-poll discount to the old waves and compared counted votes only with the polls' sampling error; with the result in hand, that took Lula's chance from 28% to 37%.) The sensitivity to this assumption is in the adjacent table: it is the choice that changes the result most, which is why it is kept in view. After the first round, runoff polls already incorporate the result and the final-poll discount applies again; each pollster's first-round error becomes a prior, at half weight, for its runoff house effect.
400,000 simulations. Two-phase drift: until Oct 4, σrw = 0.31 pp/day; between rounds, 0.95 pp/day, calibrated on the movement of Datafolha and Ibope/Ipec runoff polls between the first and last rounds in 2014, 2018 and 2022 (table below; net of sampling error and shrunk 50% toward σrw, since there are only three cycles). Total: 0.0 pp through Oct 25. Drift and polling error have Student's t tails (5 degrees of freedom, same SD): with three to five calibration cycles, the normal underestimates surprises. Regional shocks were removed from the simulation in the Oct 3 audit: the historical error was already measured on the national result, which includes the regional errors. P(opponent = Flávio) comes from the first-round simulation, with a 0% risk of disqualification.
A mixture of distributions, not just of probabilities, so the scenarios are consistent with the ensemble. The fundamentals (a line fit on three elections, with forecast error above 8 points) have had zero weight since the Oct 3 audit. The market only enters with prices up to 5 days old and, after the first round, from after Oct 4; otherwise, the ensemble is the model itself. After the first round, the official result informs the model directly. Markets react quickly to news, but they are not independent of the polls.
The buttons at the top of the page swap the headline and the chance bar for extra runs of the same model in which the runoff database keeps only the 57 polls with fieldwork after Oct 4 (19 pollsters; 19 waves from 5 pollsters with a calibrated track record: AtlasIntel, Datafolha, Ipespe, PoderData, Quaest), so that each pollster's deviation is estimated on them alone, without carrying over half the deviation measured before the first round (λ = 0.5). Everything else is the same: anchor, house-effect prior from each pollster's first-round error, drift through Oct 25 and ensemble with the markets. Full model today: margin -1.3 ± 1.17, Lula's chance 17% on polls alone and 18% in the ensemble, with 21% of the level coming from counted votes (-2.7). In the post-first-round polls + counted votes reading, the level is combined with the starting margin as in the full model (weight 21%): margin -1.9 ± 1.19, Lula's chance 11% on polls alone and 13% in the ensemble; the difference from the full model isolates the effect of estimating pollster deviations on the new waves only. In the post-first-round polls only, without counted votes reading, the level is not combined with the starting margin and the historical-error discount falls entirely on the polls (+0.9 ± 2.5, vs. an effective +0.8 in the full model): margin -1.7 ± 0.69, Lula's chance 13% on polls alone and 15% in the ensemble.
What these readings measure well: they do not depend on the assumption that half of each pollster's pre-first-round deviation persists afterward (poorly identified in the Oct 16 audit) and, in the version without counted votes, nor on the stated vote transfer. What they measure poorly: with 18 days of data the margin's daily speed is weakly identified (σrw 0.29 vs. 0.31 in the long series); pollsters with a single wave stay close to the prior; the uncertainty of the anchor's average rises (0.17 vs. 0.10); and no rolling-origin validation is possible (they inherit the main model's calibration). That is why the whole page, sharing and the no-JavaScript version follow the full model; the variants are sensitivity readings, saved in resultados_pos1t_estrut.json and resultados_pos1t.json.
The fourth button keeps the full model intact (level, house effects, counted votes, discount) and changes one thing: the drift through Oct 25, which in the main model is drawn with zero mean, gets a mean. It comes from the change panel: the aggregate of differences between consecutive waves from the same pollster, in pp per day, weighted by each difference's error and by recency (3-day half-life), currently stands at +0.40 ± 0.93 pp per week for Lula. The model uses half of it (50% shrinkage, because the difference between two waves adds up the sampling error of both and there is no historical validation of this reading) times the 0 days remaining (zero on voting day, when the variant coincides with the main model), with an SD equal to the aggregate's standard error (floor of 0.1 pp per day): mean drift 0.00 ± 0.00 pp. The parallel MCMC receives the same prior (drawn at each iteration, with no likelihood of its own). Result: Lula's chance 17% on polls alone and 18% in the ensemble (MCMC 23% and 23%), vs. 17% and 18% in the full model. Limitations: the aggregate is dominated by the 3 or 4 newest differences, changes with every published poll and cannot tell real movement from noise when pollsters disagree; that is why it is a sensitivity reading, saved in resultados_momentum.json, and not the headline.
Same model as in step 1, applied to the Lula − Flávio margin, to the sum of the two and to each minor candidate across the 75 first-round polls since August, with the same anchor. Until Oct 3, Lula and Flávio were two independent series, which ignored that they compete for the same votes and left the margin overly sensitive to the latest poll. In the simulation (200,000), candidates are drawn and renormalized to 100%. The historical first-round discount comes from the four verifiable cycles (+5.2, +10.5, +2.4, +5.8: the final polls overestimated the PT's margin over its main rival), shrunk to 60% of the recency-weighted average, with the predictive deviation between cycles (± 3.8) and Student's t tails. That error already contains the late third-way migration to the right-wing candidate (2014, 2022), so the simulated collapse only redistributes votes and adds uncertainty, without shifting the mean margin again (it would shift it by -0.5 pp). Minor candidates use a common random walk (0.17 pp/day) and only waves from the last 4 weeks: with each one's own random walk, Cury's drop in early September made the level copy the latest poll and the chance of 3rd place swing with every wave. The forecast is frozen at the last run before Oct 4 and compared with the official result.
The same model as steps 1 to 4, written as a single Bayesian model and estimated by MCMC (mcmc_2turno.py, with no libraries beyond numpy and scipy). Each poll measures the true margin plus its regime's historical discount (μ: waves through Oct 4 +4.7; later waves +0.9) and a common error β; the official first-round result with the transfer enters as a measurement of the margin without polling error. At each step of the chain, σrw and σbetween are drawn with the trajectory integrated out (given the rest, the model is linear and Gaussian), and the trajectory, β and the house effects come out together from an exact joint normal; σp and the center m come via Metropolis. Diagnostics: split R̂ and multichain effective sample size. A check in the script itself shows that, with the parameters fixed, the joint normal matches the Kalman smoother from step 1. Both models' forecasts are frozen on the eve of Oct 25 in previsoes_2turno_congeladas.json.
Parallel analysis (migracao_markov.py), conditional and with no effect on the headline: starting from the official result by state, a Markov chain takes each first-round voter (Lula, Flávio, each third-way candidate, blank/spoiled, absent) to one of the four runoff destinations via a matrix drawn row by row (Dirichlet around Quaest's stated second choice, the gross turnout flows and a profile tilt). A second chain, daily and non-homogeneous, follows the person-to-person outreach campaign from Oct 5 to 24 (reach via volunteers and chain recruitment; per-contact conversion taken from the experiments of Gerber & Green, Nickerson & Rogers and Kalla & Broockman; reversion; confirmation) and applies the converted stock as a flow between destinations, capped at the available mass. 20,000 draws per cell, across 4 reach scenarios × 2 Flávio responses, plus the "guide targets met" benchmark. Without a targeted campaign the median margin is −2.7; with the strong one, −2.7. The "with drift" chance adds the same t5 drift from step 4, only for comparison with the main model.
| Cycle | 2nd round | Final polls from the reference pollsters (valid votes) | Result | Mean error on the PT − right margin |
|---|---|---|---|---|
| 2006 | Lula × Alckmin | Datafolha 61×39 | 60.8 × 39.2 | +0.3 (weight 0.25) |
| 2010 | Dilma × Serra | Datafolha 56×44 | 56.0 × 44.0 | -0.1 (weight 0.35) |
| 2014 | Dilma × Aécio | Datafolha 52×48 · Ibope 53×47 · CNT/MDA 49.7×50.3 · Vox Populi 54×46 | 51.6 × 48.4 | +1.1 (weight 0.50) |
| 2018 | Haddad × Bolsonaro | Datafolha 45×55 · Ibope 46×54 · CNT/MDA 43.2×56.8 · Paraná Pesquisas 39.4×60.6 · Vox Populi 50×50 · Ipespe 42×58 | 44.9 × 55.1 | -1.2 (weight 0.71) |
| 2022 | Lula × Bolsonaro | Datafolha 52×48 · Ipec 54×46 · Quaest 52×48 · AtlasIntel 53.4×46.6 · PoderData 53×47 · Ipespe 53×47 · CNT/MDA 51.1×48.9 · Paraná Pesquisas 50.4×49.6 | 50.9 × 49.1 | +2.9 (weight 1.00) |
| 2026 prior | per-cycle average of the reference pollsters; cycles weighted by recency (two-cycle half-life); Veritá, Futura and Brasmarket excluded from the reference | +0.9 ± 2.0 | ||
| Discount after the first round | pre-first-round waves: observed error +4.7; post waves: +0.9; weight of post waves: 100%; on the polling share (79%) | +0.8 ± 2.0 |
| Cycle | 1st round | PT − right margin in the final polls (valid votes) | Result | Mean error |
|---|---|---|---|---|
| 2010 | Dilma × Serra | Datafolha +19.0 · Ibope +20.0 | +14.3 | +5.2 |
| 2014 | Dilma × Aécio | Datafolha +18.0 · Ibope +19.0 · CNT/MDA +18.6 | +8.0 | +10.5 |
| 2018 | Haddad × Bolsonaro | Datafolha -15.0 · Ibope -16.0 · CNT/MDA -14.8 · Paraná Pesquisas -15.3 · Vox Populi -9.0 · Ipespe -16.0 | -16.8 | +2.4 |
| 2022 | Lula × Bolsonaro | Datafolha +14.0 · Ipec +14.0 · Quaest +11.0 · AtlasIntel +9.2 · PoderData +10.0 · Ipespe +14.0 · CNT/MDA +8.6 · Paraná Pesquisas +7.1 | +5.2 | +5.8 |
| 2026 prior (first round) | 60% of the recency-weighted average (+5.7) | +3.4 ± 3.8 |
| Cycle | Pollster | First runoff poll (left × right) | Last before the vote | Margin movement |
|---|---|---|---|---|
| 2014 | Datafolha | 49 × 51 (09/10) | 52 × 48 (25/10) | +6 in 16 days |
| 2014 | Ibope | 49 × 51 (08/10) | 53 × 47 (25/10) | +8 in 17 days |
| 2018 | Datafolha | 42 × 58 (10/10) | 45 × 55 (27/10) | +6 in 17 days |
| 2018 | Ibope | 41 × 59 (14/10) | 46 × 54 (27/10) | +10 in 13 days |
| 2022 | Datafolha | 53 × 47 (07/10) | 52 × 48 (29/10) | -2 in 22 days |
| 2022 | Ipec | 55 × 45 (05/10) | 54 × 46 (29/10) | -2 in 24 days |
Movement between rounds: root mean square = 6.4 pp; net of two sampling errors per pair of polls and divided by the days, 1.31 pp/day, shrunk to 0.95 pp/day.
Today, systematic polling error accounts for 74% of the uncertainty about the final margin and the imprecision of today's aggregate (sample and house effects) for 26%; what can still change through Oct 25 weighs 0%. On voting day the drift is zero: what remains is what the polls get wrong as a bloc, the only portion that does not fade with more polls.
| Level | Historical error | Lula | Flávio |
|---|---|---|---|
| combined nowcast | recalibrated bias | 17% | 83% |
| combined nowcast | no bias correction | 27% | 73% |
| weighted average of latest | recalibrated bias | 20% | 80% |
| weighted average of latest | no bias correction | 31% | 69% |
| Discount applied | Lula | Flávio |
|---|---|---|
| +0.0 p.p. | 27% | 73% |
| +0.8 p.p. | 17% | 83% |
| +0.9 p.p. | 15% | 85% |
| +2.1 p.p. | 6% | 94% |
The reading most favorable to Lula (no discount) reaches 27%; the most favorable to Flávio (a +2.1 discount) gives Lula 6%. The markets (Lula 20%) fall within that range.
In one sentence: all 60 versions of the dashboard, from Sep 8 to today, with what changed in the method, the data and the presentation and, when measured, the effect of each change on each candidate's chance.
historico_versoes.json, in the repository, and the dashboard is generated from it.| Version | Date | Change | Measured effect |
|---|---|---|---|
| v56 | Oct 22, 2026 | The discount on runoff polls no longer inherits the first-round error, which was tactical voting; only the historical error of the final runoff rounds remains | Lula headline from 15% to 21% (main model 12.8% → 20.6%; MCMC 17.7% → 27.1%), with the same Oct 22 data; discount applied from +1.65 to +0.74; hero variants: post-first-round polls only 11.7% → 16.9%, with counted votes 12.1% → 16.4%, with the direction of the latest waves 16.7% → 23.4% (MCMC 21.2% → 30.5%) |
| v55 | Oct 21, 2026 | Change panel (differences within each pollster), fixed-effects trend gauge, hero reading with the direction of the latest waves, and consistency fixes | none on the headline (12.3% / 14.4%, margin −1.73); the fourth hero reading gives Lula 14.7% (ensemble 16.2%; MCMC 17.1% / 17.9%) with mean drift +0.33 ± 0.31; gauge from +1.1 to +1.9 pp/week when switching the pooled line for fixed effects; Futura Oct 19–20 as a new series (v2) since commit 5d0551f |
| v53.3 | Oct 18, 2026 | Weight of counted votes decays with drift; λ only on series with pre-Oct 4 waves; validation runs the dashboard's model | weight of the structural level from ~53% to 27% (declines on its own); 80% validation coverage from 82% to 85% (commit ec672a3, logged on Oct 21) |
| v53.2 | Oct 16, 2026 | λ = 0.5 on the post-first-round house effect; stated effective n; Palver v3 as a new series; 2,500 cap on AtlasIntel | Lula's chance 14.6% (model), 16.5% (ensemble); with λ = 1 it would be 18.5%, with λ = 0, 15.0% (commit 5f3d59c, logged on Oct 21) |
| v53 | Oct 6, 2026 | Migration of absent voters, third way and blank/spoiled: Markov chain + Monte Carlo, alongside the main model | none on the headline; conditional analysis: strong campaign nets +130,000 votes; the guide's targets would require conversion 7 to 29× that in the literature |
| v51 | Oct 5, 2026 | Parallel runoff model estimated by MCMC (full posterior), alongside the main model | none on the headline; MCMC: Lula 21% vs. 20% in the main model |
| v45 | Oct 4, 2026 | Official result; correction of the post-first-round discount; analysis of the comeback, the polls and the model's accuracy | Lula (polls only) 28.4% on the eve → 36.9% under the old rule → 18.1% corrected; ensemble 17.4% |
| v44 | Oct 3, 2026 | First-round forecast frozen before the vote | — |
| v43 | Oct 3, 2026 | Second audit: post-first-round regime, t tails, fundamentals at zero weight, herding test | ensemble 34.2% → 31.9%; Lula 1st in the first round 38% → 45% |
| v41 | Oct 3, 2026 | Audit: third way without double counting, pre-first-round discount in the runoff, homogeneous fundamentals | Lula (polls only) 50% → 31%; ensemble 48% → 35% |
| v39 | Oct 2, 2026 | State-level reading: map by state and state polls in the national margin | — |
| v37 | Oct 1, 2026 | Abstention enters the model (turnout scenarios by state and profile) | — |
| v33 | Sep 30, 2026 | Audit and model v3: anchored state space, recalibrated bias, first-round forecast | Lula (ensemble) 37.7% → 47% |
| v28 | Sep 28, 2026 | Trend thermometer for the last two weeks | — |
| v25 | Sep 24, 2026 | Model v2: state polls, Kalman filter and new factors | see the "What changed in the model" section |
| v16 | Sep 17, 2026 | Bias section: sample flaws, margin of error and historical calibration | — |
| v2 | Sep 8, 2026 | Weighting by recency, sample size and quality; database of 41 waves | — |
| v1 | Sep 8, 2026 | First version: simple average of the latest polls from five pollsters | — |
RHO_1T_2T = 0: post-first-round waves receive only the discount from the final runoff rounds, whose sign varies (2018 −1.2; 2022 +2.9), with σ = 2.5 kept. Runoff waves fielded before Oct 4 keep the observed error, but weigh less than 1% of the margin. Unchanged: momentum remains only a variant (fourth hero button, drift with half the aggregate of within-pollster differences), because the pace fell from +3.0 to +1.8 pp/week and, in 2014 and 2018, extending the trend beyond the last poll would have erred more. In the same critique, full momentum would give 26.3% (MCMC 35.0%). Caveat: this is a method change three days before the election, prompted by a critique made after seeing the data. The justification is the breakdown of the first-round error, which predates this week and does not depend on the direction of the runoff polls.analise-modelo-agregacao-2026-10-21.md) prompted by the critique that the model carries inertia from the past and should read the "present-day drive" in within-pollster differences. (1) New #mudanca section (mudanca_2turno.py): for each series with two consecutive waves after Oct 4, the change in the valid-vote margin, weighted by the error of the difference (sum of the two sampling errors) and by recency (3-day half-life); the literal version (only each house's latest difference) alongside; v2/v3 series do not pair with the old one; Gerp tracking only at 5 days or more; rejection and available room (100 − vote − rejection) in the same machinery when there are post-first-round waves in rejeicao_2026.csv. Today: +1.17 ± 1.08 pp/week for Lula, slowing (long window +1.55). (2) Gauge: slope with a per-series intercept (fixed effects), which cancels the house effect without estimating it and stops confusing composition with trend; weights using σinst instead of the residuals' RMS (which already contained the sampling error); pace needed to overturn based on the model's polling level; "Comeback under way" only when the pace catches up. (3) ELEICAO_MOMENTUM=1 variant (resultados_momentum.json, fourth hero button, step 5c): the drift through Oct 25 gets a mean = half the aggregate × days remaining, with the SD shrunk in the same proportion (floor 0.1/day); the MCMC receives the same prior. (4) Consistency: the error of the thermometer series and of serie_pos1t from the polls only (previously the merged level's); bias_prior_sd is the prior again; the frozen file stores nivel_pesquisas and nivel_ajustado; MCMC formula with the Oct 4 structural SD (2.5); notes for Palver v3, MDA v2 and Futura v2; first-round error prior explicitly not applied to the new series. Also logged on this date: versions v53.1 to v53.4 (Oct 13–19), which existed only in the commits.modelo_eleicao_2026.py (variable ELEICAO_SO_POS1T: estrut writes resultados_pos1t_estrut.json, with counted votes combined as in the full model; 1 writes resultados_pos1t.json, without them): the runoff database keeps only waves with fieldwork after Oct 4, each pollster's house effect is estimated on them alone (without carrying over 50% of the deviation measured before the first round) and, in the version without counted votes, the level is not combined with the starting margin and the historical-error discount falls entirely on the polls. Anchor, first-round error priors, drift and ensemble are the same. The hero gets three buttons; the JS swaps the headline, subtitle, bar and caption, and a note explains where the difference comes from. Step 5b of the methodology records what the reading measures well (independent of λ and of the stated transfer) and poorly (12 days do not identify σrw; single-wave pollsters stay at the prior; no rolling-origin validation). The main run does not change: zero difference in resultados.json other than timestamps.#evolucao section (evolucao_pesquisas.py): Lula and Flávio points per poll, reading by point and by day, table with sources; summary line = the model's adjusted margin day by day (does not change with the selector); with a partial selection, a dashed simple average of the selected ones (4-day half-life, 14-day window).n_efetivo); Ipespe is CATI. In the model, the house effect measured before the first round counts at half (λ = 0.5) in the new waves, because the observed slope was +0.58 ± 0.68 and the spread across pollsters grew with the full effect; AtlasIntel with a maximum effective n of 2,500.#ponteiro section (ponteiro_tendencia.py): slope of the Lula − Flávio margin in polls with fieldwork after Oct 4, corrected for house effect, with a ±3 pp/week prior while there are few polls; gauge with today's needle, the one from 3 days ago and a mark for the pace needed to overturn.migracao_markov.py and Migration section in Part 2, between Abstention and Scandals. Starting from the official result by state, a Markov chain takes each first-round voter (Lula, Flávio, each third-way candidate, blank/spoiled, absent) to one of the four runoff destinations via an 8 × 4 matrix drawn row by row (Dirichlet around Quaest's stated second choice, the gross turnout flows and a profile tilt); a second chain, daily and non-homogeneous, follows the person-to-person outreach campaign from Oct 5 to 24 (volunteers and chain recruitment, per-contact conversion taken from Gerber & Green, Nickerson & Rogers and Kalla & Broockman, reversion, confirmation), with a symmetric response from Flávio and an open call that backfires in his states. 20,000 draws per cell, 4 reach scenarios × 2 responses plus the "guide targets met" benchmark. Result: without a targeted campaign the margin goes to −3.85 (80% between −4.7 and −3.0; reproduces the transfer model); the strong campaign (500,000 initial volunteers, 1.3 million by the end, 3.5 million names) nets +130,000 votes and moves the margin 0.12 point; the maximum one, +250,000; with a symmetric response from Flávio, +50,000. The guide's targets (+5.1 million) would take the margin to +0.4, but would require, at that reach, per-contact conversion 7× (absent voters) to 29× (Flávio voters) that in the literature. Lula's chance "with drift" (same t5 drift as the main model, only for comparison): 14.6% without a campaign, 15.2% with the strong one. Self-checks in the script: mass conserved, rows sum to 1, chain in [0, 1], reproduction of the structural model (−3.85 = −3.85), monotonic ladder, fixed seed. Step 8 of the methodology and two new glossary terms (Markov chain, Dirichlet). The headline remains the main model.mcmc_2turno.py: the same runoff model (polls in the window, house effects anchored with a prior from each pollster's first-round error, the Oct 4 break, the structural level of the counted vote, regime-based bias discount with t tails, two-phase drift), written as a single Bayesian model and estimated by MCMC, with 4 chains of 4,000 draws. σrw and σbetween are drawn with the trajectory integrated out; trajectory, common error and house effects come from an exact joint normal; σp comes from Metropolis. Diagnostics: R̂ and effective sample size. At launch, today's true margin is the same in both (−3.9); Lula's chance is 21% in the MCMC versus 20% in the main model, because the speed of change between rounds is no longer fixed and widens the drift (deviation 5.4 × 4.2). Both forecasts will be frozen on the eve of Oct 25 in previsoes_2turno_congeladas.json. The headline is still the main model; none of its numbers changed.coletar_municipios.py; 2022 Open Data read via HTTP Range, coletar_2022_2t.py) and the IBGE minimal mesh (gerar_geo_mun.py). 11 municipalities still show a partial count in the TSE municipal file. Accuracy: new block "Forecast frozen on the eve × official result", with 80% bands against the result (7 of 8 inside; only "others" outside), the four eve-of-election questions (4 of 4 on the most likely outcome; mean Brier 0.108) and the per-candidate error of the model versus undiscounted polls (0.35 × 1.22 pp; the model came closer on all 7).resultado_1turno.csv (triggers the post-first-round regime) and resultado_1turno_uf.csv (27 states and Abroad). Method: (1) with the result and no new poll, the discount on runoff polls fell from +3.4 to +2.0 and Lula's chance rose from 28% to 37%, in the opposite direction to the error the result revealed. Runoff waves conducted before Oct 4 now receive the anchor's observed first-round error (+4.8); those conducted afterward, the post-first-round rule (runoff finals + 25% of the error, +2.1), blended by the share of the margin that comes from new waves in the filter (F = w_pós ÷ (w_pós + 1/(P + 6²))); (2) the counted vote with vote transfer (−3.9) is now combined with the polls' total error (sampling and systematic), not just sampling: weight from 8% to 52%; corrected polls and counted vote now converge (−3.9 and −3.9); (3) a market read on the night of the count counts in the ensemble if marked as after the result (Polymarket: Lula 15%, US$170M, two mirrors). Tested on a copy with 0, 1 and 3 fictitious post-first-round waves (smooth transition). Dashboard: first-round top section with the result beside the frozen forecast; new sections "Why Flávio flipped it", "Polls × ballot boxes" (error by pollster, method, region and state; house correction against the ballot box; herding test) and "Accuracy" (error, percentile, Brier and log loss, component by component); runoff winning conditions redone from the result; version history now lives in historico_versoes.json and becomes the methodology section "History of changes and adjustments", in the dashboard and on the site. Assessment: forecast margin −0.7 versus −1.90 (error 1.2; undiscounted polls: 4.6); result at the 37th percentile; the historical discount worked and was conservative; the anchored house correction pushed 11 of 14 pollsters away from the result; the state-level reading got the Southeast wrong.previsoes_1turno_congeladas.json for comparison with the Oct 4 result.transferencia_2turno.csv. First round: (7) Lula and Flávio are no longer two independent series: the model estimates the margin and the sum of the two; (8) historical discount with 2010 in the base and predictive deviation across cycles (± 3.8 instead of ± 3.0). Specification: (9) Student t tails in poll error and drift; (10) pollster quality measured by error relative to the cycle average, not raw error; (11) anchor uncertainty added to the level error; (12) fundamentals at zero weight (a line through three elections); (13) regional shocks removed; (14) herding test redone on residuals; (15) margin sampling error with the exact formula; (16) separate validation for known and new pollsters, and an influence table for each poll. Effect, with the same polls: Lula from polls alone 29.8% → 29.9%; ensemble 34.2% → 31.9% (the fundamentals component, which said 53%, drops out); Lula 1st in the first round 38% → 45%. Dashboard: update time in the header; discount labels fixed (the dashboard called the first-round discount "2006–22 runoffs"); empty links for state polls; texts with hard-coded numbers now come from the data.abstencao_2022_uf.csv (abstention by state in both 2022 rounds, CNN Brasil from TSE, single source; Amapá and Roraima to be checked). Check: the 2022 margin rebuilt by region matches the official result. No new national poll as of 6:40 p.m.; the Datafolha poll of Sep 28–Oct 1 (n=4,006) was announced for today but had not yet been published. Model: level +0.3 pp; Lula 45% · Flávio 55% from polls alone; ensemble Lula 45% · Flávio 55%.vies), between "Methodology" and "Undecided". It measures, in the base itself, that a poll's typical error is 4.4 pp versus 2.2 pp predicted by sampling (1.97×, in line with Shirani-Mehr et al./JASA 2018); shows that the stated margin refers to each candidate and that the difference between the two already carries ±4.4 pp; applies a herding test (observed dispersion 1.43× the expected — the opposite of the 2024 US pattern); includes a forest plot of house effects with 95% intervals; a profile of the 19 pollsters (method, sponsor, bias, typical vs. theoretical error, track record); the series of errors in finals from 2010 to 2022 with telling cases (Vox Populi and Paraná in 2018, the ~8.8 pp error against Bolsonaro in the 2022 first round, SP 2020 and 2024); the regulatory framework (PesqEle, fines, PL 2.558/2022) and the 2026 episodes (Áltica, the two lawsuits against AtlasIntel, the suspension of Veritá); and a conclusion ranking the pollsters by reliability. Calibration fix arising from the research: the dashboard's track record recorded only Paraná Pesquisas' 2022 performance, which is why it received the best quality weight — but the pollster missed the 2018 runoff by ~11 pp. Verified finals were added for Paraná (2018), Veritá and Futura (2022), and the quality weight is now shrunk toward the neutral value according to the number of cycles behind it. Effect: nowcast from −1.5 to −1.7 pp, P(Lula) in the model from 28.6% to 27.7% and ensemble from 40.1% to 39.6%.In one sentence: the published margin of error covers only part of the real error — this cycle, the typical error of a single poll is 1.72 times what the margin promises, which is why the dashboard never trusts a single poll.
It assumes simple random sampling, a complete list of the population and a response from everyone drawn — three conditions no Brazilian election poll meets. And there is a reading trap: the margin applies to each percentage, not to the gap. When Lula has 46% and Flávio 45% with a "2-point margin", the 1-point gap carries, from sampling error alone, an interval of ±4.4 pp — because the errors of the two percentages add up. Statistical ties are therefore far more common than the usual reading suggests.
Design effect: quotas and clustering inflate the variance; in the US, the real standard error is ≈1.3× that of the simple formula (median; Shirani-Mehr et al., JASA 2018), and quota samples taken at high-traffic street points tend toward a larger factor. Nonresponse: rates above 90% in US election polls — and nonresponse is differential: supporters of the trailing candidate respond less, a bias no demographic weighting corrects. Coverage: telephone excludes people without a line (~4% of adults), online panels exclude offline and less-educated voters, face-to-face stumbles on gated communities. Weighting: calibrating on reported vote in the previous election imports the respondent's memory error. Likely voters and undecided: turnout criteria and the reallocation of the undecided are assumptions, not measurements — and different pollsters use similar assumptions, which makes their errors move together.
For each of the 221 polls in the base, we can compare the observed deviation from the common trend estimated by the aggregator with the deviation sampling theory predicts for that sample. The typical observed error is 3.9 pp on the valid-vote margin, versus 2.3 pp predicted — a ratio of 1.72×. Translated into a 95% interval: the honest interval for a single poll, this cycle, is about ±7.6 pp on the margin, not the ±2.0 shown in the technical sheet. The match with the international literature is striking: analyzing 4,221 polls from 608 US elections, Shirani-Mehr, Rothschild, Goel and Gelman found an average error of 3.5 pp — also about double what the published intervals imply.
The systematic share — each pollster's bias — is the largest slice and is correctable: that is exactly what the aggregator does when it estimates house effects. What remains after that is still 1.1× the theoretical: the excess noise no technical sheet declares.
Herding is the artificial convergence of pollsters near the election — publishing what the consensus already says, so as not to be wrong alone. In the 2024 US election, Nate Silver showed that 78% of swing-state polls fell within ±2.5 pp when theory predicted 55%: the probability of that happening by chance, 1 in 9.5 trillion. Here the test is run on residuals: after removing the trend and each pollster's house effect, the 57 waves of the last 21 days deviate by 1.7 pp on average, versus 2.2 pp expected from sampling error alone — a ratio of 0.78×. Close to 1: the dispersion is what chance explains, with no sign of herding (part of the distance from 1 comes from the fit itself absorbing noise). Raw margins, by contrast, vary by 2.0 pp across pollsters: Brazil's divergence comes from real, persistent methodological house effects, not noise. (Until Oct 3 this test compared raw margins with sampling error, which always gave a ratio above 1 and did not measure herding.) The practical consequence is that a simple poll average (used by most press aggregators) is a poor estimator: without correcting house bias, it reflects whoever published most that week.
A word of caution on interpretation: a house effect is not a synonym for political bias. It measures distance from the anchor, not distance from the result — and it usually reflects method choices (who polls face-to-face, who polls online, who reallocates the undecided). The consensus can be wrong too: in 2022, the two pollsters closest to the final runoff result were precisely two that departed from the average.
After the first round, house effects count for half. In polls conducted after Oct 4, the observed margin tracks the previously estimated house effect only in part (slope of +0.58, with an interval of ±0.68, where 1 would mean full persistence and 0 none; without Gerp, close to zero), and the spread across pollsters increases when the old effect is applied in full. Several pollsters began weighting their samples by declared first-round vote, which erases part of the differences. The model applies 50% of the historical house effect to polls after Oct 4 (the "House effect" column above shows the value applied). Palver changed its calibration method in the Oct 10–13 wave (v3, with first-round vote by region), and the pollster itself says the wave is not comparable with earlier ones: it enters as a new series, without the house effect of earlier waves, and with the Kish effective n it reports (1,783 instead of 5,000). AtlasIntel's effective n is capped at 2,500, because its declared margin (±1.0) is smaller than that of a simple random sample of the same size (±1.4) and the design effect is not disclosed.
| Pollster | Method | Average n | House effect | Typical error | Theoretical error | Ratio | MAE 2006–22 | Reading |
|---|---|---|---|---|---|---|---|---|
| Gerp · 20 waves · latest Oct 24 | phone | 2,220 | -7.7 * | 7.4 | 2.2 | 3.31× | — | Use with caution |
| Futura Inteligência · 19 waves · latest Oct 17 | phone | 2,000 | -3.8 * | 6.0 | 2.3 | 2.58× | 2.4 | Use with caution |
| Quaest · 19 waves · latest Oct 24 | in-person | 2,198 | -1.0 | 1.8 | 2.4 | 0.75× | 4.0 | Reliable, with caveats |
| BTG/Nexus · 19 waves · latest Oct 24 | phone | 2,007 | -1.1 | 1.5 | 2.3 | 0.65× | — | Most reliable |
| Datafolha · 18 waves · latest Oct 24 | in-person | 2,345 | 0.0 | 2.1 | 2.2 | 0.93× | 3.4 | Most reliable |
| AtlasIntel · 17 waves · latest Oct 23 | online | 5,215 | -0.5 | 1.8 | 2.1 | 0.85× | 4.5 | Reliable, with caveats |
| PoderData · 17 waves · latest Oct 24 | phone | 2,964 | -2.4 * | 2.0 | 1.9 | 1.04× | 4.5 | Reliable, with caveats |
| Meio/Ideia · 11 waves · latest Oct 19 | phone | 1,636 | -0.9 | 1.7 | 2.6 | 0.64× | — | Reliable, with caveats |
| Vox Brasil · 11 waves · latest Oct 22 | in-person | 2,100 | -1.4 | 3.8 | 2.3 | 1.62× | — | Use with caution |
| Veritá · 9 waves · latest Oct 23 | phone | 12,912 | -5.3 * | 6.5 | 1.6 | 3.99× | 3.8 | Use with caution |
| Real Time Big Data · 9 waves · latest Oct 22 | phone | 2,000 | -1.6 | 1.9 | 2.4 | 0.78× | — | Most reliable |
| Indexa · 8 waves · latest Oct 21 | phone | 2,000 | +1.1 | 1.6 | 2.4 | 0.68× | — | Reliable, with caveats |
| Alfa Inteligência · 6 waves · latest Oct 22 | in-person | 2,200 | +0.3 | 2.1 | 2.4 | 0.86× | — | Use with caution |
| Palver · 6 waves · latest Oct 3 | online | 5,000 | -4.4 * | 4.2 | 2.8 | 1.54× | — | Use with caution |
| CNT/MDA · 5 waves · latest Oct 2 | in-person | 2,003 | +3.9 * | 6.4 | 2.4 | 2.69× | 3.9 | Use with caution |
| DataTrends · 5 waves · latest Oct 21 | phone | 2,000 | -1.8 | 2.2 | 2.3 | 0.94× | — | Insufficient series |
| Paraná Pesquisas · 4 waves · latest Mar 28 | in-person | 2,069 | -2.0 * | 3.2 | 2.4 | 1.37× | 3.8 | Reliable, with caveats |
| Instituto França · 3 waves · latest Oct 23 | not reported | 2,179 | -0.5 | 0.9 | 2.4 | 0.40× | — | Insufficient series |
| Ipespe · 3 waves · latest Oct 22 | phone | 1,500 | -0.9 | 1.3 | 2.7 | 0.48× | 4.9 | Use with caution |
| Palver (v3 calibration) · 3 waves · latest Oct 24 | online | 5,000 | -0.3 | 0.8 | 2.4 | 0.32× | — | Use with caution |
| Vetor/Arrow · 2 waves · latest May 18 | not reported | 9,000 | -0.4 | 10.4 | 1.5 | 7.02× | — | Insufficient series |
| American Analytics · 2 waves · latest Sep 20 | not reported | 2,000 | 0.0 | 0.4 | 2.4 | 0.16× | — | Insufficient series |
| CNT/MDA (weighted by first round) · 2 waves · latest Oct 23 | in-person | 2,023 | -0.3 | 0.5 | 2.3 | 0.23× | — | Use with caution |
| Futura (education from PNAD) · 2 waves · latest Oct 24 | phone | 2,000 | -0.4 | 0.8 | 2.3 | 0.33× | — | Use with caution |
| Colectta · 1 wave · latest Oct 23 | in-person | 3,000 | +0.2 | 1.8 | 1.9 | 0.94× | — | Insufficient series |
Positive "house effect" = shows Lula better than the anchor; negative = shows Flávio better; * marks statistically significant effects (95%). "Typical error" is the observed deviation from the common trend; "theoretical error" is what sampling predicts for that n. A "ratio" above 2 means the pollster misses by twice what its methodology statement suggests — and a large sample does not fix it: Veritá and Vetor/Arrow declare the smallest margins in the database (samples of 3.8 thousand to 40 thousand) and have the largest ratios, because a large sample reduces sampling error without touching bias.
This is not opinion: PollingData measured, across 4,400 polls registered with the TSE, that in 2022 Lula's average lead was 14.1 pp in face-to-face polls versus 9.3 pp in phone polls in the first round (a 4.8 pp gap), and 17.6 versus 13.8 in the runoff. The direction repeats in 2026 for perception variables: Datafolha (face-to-face, street intercept) measures rejection at 46% for Flávio and 45% for Lula; Quaest (face-to-face, in-home), 54% and 52% in the same month — a 7 to 8 pp gap on the variable that sets each candidate's ceiling. On government approval, PoderData (IVR) and Quaest diverged by as much as 10 pp in December 2025. Self-administered methods (online, IVR) tend to measure more right-leaning vote than interviewer-based methods — consistent with lower social desirability bias.
In the US, polls sponsored by campaigns and parties show an average bias of 4 to 5 pp in favor of the sponsor (FiveThirtyEight). In Brazil, JOTA estimates house effects by pollster and has recorded deviations of around 4 pp in polls from pollsters tied to corporate sponsors. In this dashboard's database, however, the pattern does not line up by sponsor. The largest deviations from the anchor are: Gerp (-7.7, pro-Flávio); Veritá (-5.3, pro-Flávio); Palver (-4.4, pro-Flávio); CNT/MDA (+3.9, pro-Lula). They include both self-funded pollsters and pollsters with a sponsor. In other words: in the 2026 cycle, bias is more associated with method and lack of scrutiny than with who pays the bill.
| Cycle | What the final polls projected | Result | Margin error | Lesson |
|---|---|---|---|---|
| 2010 · runoff | Datafolha and Ibope: Dilma 56 × 44 | 56.05 × 43.95 | ≈0.2 | The most accurate cycle in the series — when the race is not polarized by identity, polling works well |
| 2014 · runoff | Vox Populi 48×41 · Datafolha 47×43 · Ibope 49×43 · CNT/MDA had Aécio ahead | Dilma 51.6 × 48.4 | 0.6 to 3.9 | First recent case of a wrong winner from a traditional pollster; the first round underestimated Aécio by ~9 pp |
| 2018 · runoff | Datafolha 55×45 · Ibope 54×46 · Vox Populi 50×50 · Paraná Pesquisas 60.6×39.4 | Bolsonaro 55.1 × 44.9 | 0.3 (Datafolha) to 10.9 (Paraná) | The same cycle produced the best and the worst performance in the series — the pollster average hides brutal dispersion |
| 2022 · first round | Datafolha and Ipec: Lula +14 in valid votes | Lula +5.2 | ≈8.8 | Systematic error against Bolsonaro, with deviations of up to 20 pp in some states — the origin of this dashboard's bias prior |
| 2022 · runoff | CNT/MDA 51.1×48.9 · Paraná 50.4×49.6 · Datafolha and Quaest 52×48 · Ipec 54×46 · Veritá, Brasmarket and Futura had Bolsonaro winning | Lula 50.9 × 49.1 | 0.4 to 9.0 | The two most accurate were outside the consensus; three pollsters got the winner wrong — two of them are still in the 2026 database |
| Municipal 2020/2024 | SP 2020: Covas 38 (final Ibope) · SP 2024: Boulos 27 × Nunes 24 × Marçal 24 (Datafolha) | Covas 32.9 · Nunes 29.5 × Boulos 29.1 × Marçal 28.1 | 4 to 5.5 | In 2024 the order of the top three was reversed — an ordering error, not just one of magnitude |
Convention: margin error = (projected left−right margin) − (official margin), in percentage points of valid votes. This series feeds the systematic-error adjustment used in the simulation (+0.8 ± 2.0 pp for the runoff — observed polling error in the first round for waves before Oct 4, and final runoff rounds from 2006–2022 plus 25% of that error for the new waves; +3.4 ± 3.8 for the first round). Note the asymmetry between rounds: large, systematic bias shows up mainly in first rounds (2014 and 2022), while runoffs have smaller and more scattered bias.
Under Law 9.504/1997 (art. 33), every poll must be registered in PesqEle at least 5 days before publication, with sponsor, amount paid, sampling plan, fieldwork period and margin of error; afterwards, the final sample composition must be filed. Publishing without registration carries a fine of R$ 53,000 to R$ 106,000, including for those who merely share it. Registration, however, is a transparency formality, not a seal of quality: there is no prior audit of method and no penalty for being wrong. The bill that would have barred for 8 years any pollster missing beyond its margin (PL 2.558/2022) did not advance, and the polling inquiry (CPI) requested in 2022 was never set up.
Three episodes from 2026 show the size of the gray zone. February: the TSE suspended a poll by Áltica Research published without prior registration. June and July: Flávio Bolsonaro's campaign took AtlasIntel to the TSE twice — the first time, alleging the poll led respondents by playing audio from the Vorcaro case before the image questions, it won an injunction from the TSE president suspending publication; the second time, it challenged the supplementary documentation of a wave showing Lula +7. August: Veritá polls were suspended by the Electoral Court after questions about methodology, sample composition and fieldwork schedule — the same pollster that registered 93 self-funded polls during the year. Taking polls to court is itself a campaign tool: it is worth separating the methodological merits from the political use of the challenge.
No pollster is infallible, and the right question is not "who is right today" but who misses by less and more predictably. I combined four verifiable criteria: historical accuracy in actual presidential elections (2018 and 2022), size and statistical significance of the 2026 house effect, excess error over the theoretical, and transparency of method, sponsor and registration. The result below is an explicit judgment — not an automatic index — and each name comes with its caveat.
In 2026: 19 waves, house effect of -1.1 pp, indistinguishable from zero, typical error below what the sample implies. The densest series of the cycle, with biweekly waves since March, and one of the smallest typical errors in the database. Phone method and sponsor disclosed in every wave. Caveats: it has no presidential track record of its own to gauge accuracy, and such low dispersion is also consistent with smoothing across waves.
In 2026: 18 waves, house effect of 0.0 pp, indistinguishable from zero, typical error close to theoretical. The country's longest series, face-to-face street-intercept method, and the best historical accuracy among the major pollsters (MAE of 3.4 pp on the margin, 2006–22, including a near-exact call of the 2018 runoff).
In 2026: 9 waves, house effect of -1.6 pp, indistinguishable from zero, typical error below what the sample implies. Phone method and registration disclosed, typical error below theoretical. Caveats: no verifiable presidential track record and a short series.
In 2026: 19 waves, house effect of -1.0 pp, indistinguishable from zero, typical error below what the sample implies. Long, face-to-face in-home series, the most expensive method and, in theory, the one with the best coverage, with registration and sponsor disclosed. Against: the worst historical MAE among the major pollsters (4.0 pp). It measures rejection higher than Datafolha, a difference of question wording and method, not of timing.
In 2026: 17 waves, house effect of -0.5 pp, indistinguishable from zero, typical error below what the sample implies. Samples of 5,000, high frequency and the best performance in the 2022 first round. Against: it is a non-probability online poll (digital recruitment) and was the target of two PL lawsuits in 2026, one of them resulting in a suspension injunction. A large sample shrinks the declared margin without shrinking the real error.
In 2026: 17 waves, house effect of -2.4 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error close to theoretical. Fixed schedule, method (IVR) and TSE registration published in every wave. Verifiable track record, albeit middling (MAE of 4.5 pp).
In 2026: 11 waves, house effect of -0.9 pp, indistinguishable from zero, typical error below what the sample implies. Phone waves with TSE registration and disclosed sponsor, good frequency. Against: smaller sample than the database average and no presidential track record of its own.
In 2026: 8 waves, house effect of +1.1 pp, indistinguishable from zero, typical error below what the sample implies. Phone poll, media sponsor (Broadcast/Estadão) and registration disclosed. Against: short series and no verifiable presidential track record.
In 2026: 4 waves, house effect of -2.0 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error 1.4× theoretical. An instructive case: it nailed the 2022 runoff almost exactly when the consensus missed, but had missed 2018 by ~11 pp (combined MAE of 3.8 pp). Its latest wave is from March: it is outside the current reading.
In 2026: 20 waves, house effect of -7.7 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error 3.3× theoretical. Self-funded and with no verifiable presidential track record. This does not imply bad faith: it may be a methodological choice that better captures right-leaning voters, but there is no way to audit it.
In 2026: 19 waves, house effect of -3.8 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error 2.6× theoretical. It projected the wrong winner in the 2022 runoff, in the same direction as its current deviation: the bias is not new.
In 2026: 11 waves, house effect of -1.4 pp, indistinguishable from zero, typical error 1.6× theoretical. Face-to-face in-home and self-funded in recent waves (TSE registration disclosed); waves through May did not disclose the method. No verifiable presidential track record.
In 2026: 9 waves, house effect of -5.3 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error 4.0× theoretical. Huge samples (up to 40,000) and a declared margin among the smallest in the database, with observed error several times the theoretical: the clearest illustration of false precision. It had polls suspended by the Electoral Court in August 2026 after questions about methodology and sample composition, and it projected the wrong winner in 2022.
In 2026: 6 waves, house effect of +0.3 pp, indistinguishable from zero, typical error below what the sample implies. Samples smaller than the database norm, method not disclosed and no verifiable track record.
In 2026: 6 waves, house effect of -4.4 pp (shows Flávio better than the anchor, a statistically significant deviation), typical error 1.5× theoretical. Data collection via WhatsApp with ad-based recruitment and raking calibration. On the other hand, it has the most complete methodology statement of the cycle and promised to release its microdata after the runoff.
In 2026: 5 waves, house effect of +3.9 pp (shows Lula better than the anchor, a statistically significant deviation), typical error 2.7× theoretical. The hardest case in the database. It has good historical accuracy (MAE of 3.9 pp; it was the closest to the result in the 2022 runoff) and, in 2026, it is the anchor pollster that most often shows Lula ahead. Either it captures something the others miss, as in 2022, or it has been an outlier this cycle. Few waves, all face-to-face, sponsor disclosed (CNT).
In 2026: 5 waves, house effect of -1.8 pp, indistinguishable from zero, typical error close to theoretical. Debuts in the database with IVR. No track record and a short series for measuring house effects (the rating does not change its weight in the model: each wave is weighted by its sampling error and by the house's extra noise).
In 2026: 3 waves, house effect of -0.5 pp, indistinguishable from zero, typical error below what the sample implies. Phone (CATI), self-funded, no verifiable track record. The Oct 16–20 report says it "consolidates surveys" and the overall total does not add up with the breakdowns: no age breakdown has more than 4.1% "don't know" (the total says 8.84%), and the vote by region, weighted by the report's own weights, gives Lula ~41.6 × Flávio ~39.3, the reverse of the total.
In 2026: 3 waves, house effect of -0.9 pp, indistinguishable from zero, typical error below what the sample implies. Returns to the database in the 2026 runoff with a verifiable presidential track record (the XP/Ipespe series of 2018 and 2022), but a middling one: it missed the runoff margin by 5.7 pp in 2018 and 4.2 in 2022, in opposite directions. Small sample (1,500) and releases that disagree about the method (digital recruitment × CATI phone).
In 2026: 3 waves, house effect of -0.3 pp, indistinguishable from zero, typical error below what the sample implies. Palver's series after its weighting change (v3, from Oct 10–13 onward): same house and same method, but the house effect is estimated only on the new waves, which is why it appears separately. The declared effective n (Kish) is what enters the error.
With 2 waves, there is no series to measure a house effect. Two waves with samples of 9,000 that moved more than 10 pp apart in two months, the largest gap in the database relative to what the sample would allow. No track record and no declared method.
With 2 waves, there is no series to measure a house effect. Short series, no verifiable track record in Brazil and no declared method.
With 2 waves, there is no series to measure a house effect. CNT/MDA's series after its weighting change (v2, from Oct 13–15 onward): same house, house effect estimated only on the new waves, kept out of the anchor until it has a series.
With 2 waves, there is no series to measure a house effect. Futura's series after switching its education weighting (TSE voter roll → PNAD Contínua, from the Oct 19–20 wave onward): same house, house effect estimated only on the new waves.
With 1 wave, there is no series to measure a house effect. Debuts in the database on the eve of the runoff: face-to-face, one day of fieldwork (Oct 23), disclosed sponsor (Brazil Economy) and TSE registration BR-05601/2026. No verifiable track record and, with only one wave, no series to measure a house effect.
Correction made in this update. While compiling the track record for this section, I found a flaw in the dashboard's own calibration: Paraná Pesquisas was entering with the best quality weight in the database (lowest historical error) because only its 2022 performance was recorded — but the pollster missed the 2018 runoff by about 11 pp, the largest individual deviation in the series. The track record was completed (Paraná in 2018; Veritá and Futura in 2022), and the quality weight is now shrunk according to the number of cycles supporting it: with a single verified election, the pollster stays close to the neutral weight. The nowcast and probabilities in this version already reflect the correction.
Sources for this section. Total error vs. declared margin: Shirani-Mehr, Rothschild, Goel & Gelman, "Disentangling Bias and Variance in Election Polls", JASA 113(522), 2018. Herding: Nate Silver, "There's more herding in swing state polls", 2024. Face-to-face × phone method: PollingData (database of 4,400 polls registered with the TSE). House effects and sponsor bias: FiveThirtyEight and JOTA (house-effects aggregator). Historical series: Congresso em Foco, O Povo, Poder360, Metrópoles, CartaCapital, O Janelão and Wikipedia (presidential polls 2014/2018/2022). Regulation and 2026 episodes: TSE (PesqEle, Res. 23.747/2026 and decisions of Feb., Jun. and Jul. 2026), Senate (PL 2.558/2022), JOTA, Brasil de Fato and Correio da Manhã. Transparency: AAPOR Transparency Initiative, ABEP and ABRAPEL.
In one sentence: every technical term used in the dashboard, explained without jargon. The same texts appear when you tap the underlined words.
| Datafolha (fieldwork) | Great/good | Fair | Bad/terrible | Approve | Disapprove |
|---|---|---|---|---|---|
| Dec 2024 | 35 | 29 | 34 | — | — |
| Feb 10–11, 2025 | 24 | 32 | 41 | — | — |
| Sep 2025 | 33 | — | 38 | 48 | — |
| Dec 2–4, 2025 | 32 | — | 37 | 49 | 48 |
| May–Jun 2026 | 32 | 28 | 38 | 49 | 48 |
| Jul 22–23, 2026 | 32 | 28 | 38 | 49 | 48 |
| Aug 18–20, 2026 | 33 | 25 | 41 | 47 | 50 |
| Sep 1–2, 2026 | 31 | 25 | 42 | 45 | 51 |
| Genial/Quaest (month) | Jan '25 | Mar '25 | May '25 | Jul '25 | Sep '25 | Oct '25 | Jan '26 | Apr '26 | May '26 | Jun '26 | Jul '26 | Aug '26 | Sep 1 '26 | Sep 6 '26 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Approve | 47 | 41 | 40 | 43 | 46 | 48 | 47 | 43 | 46 | 47 | 48 | 46 | 45 | — |
| Disapprove | — | — | 57 | 53 | 51 | 49 | 49 | 52 | 49 | 48 | 47 | 48 | 48 | 50 |
| Other pollsters | Fieldwork | Approve | Disapprove | Great-good / fair / bad-terrible | Note |
|---|---|---|---|---|---|
| AtlasIntel/Bloomberg | Dec 10–15 '25 → Jun '26 → Jul 22–27 → Aug 25–30 | 48.8 → 46 → 47.6 → 45.4 | 50.7 → 52 → 51.2 → 52.9 | 37.0 / 11.9 / 51.1 (Aug) | Disapproval: evangelicals 76%, youth 16–24 77%, South 68%. Approval: Northeast 55–67%, 60+ 63%, Catholics 55% |
| PoderData/Aya | May → Jun → Jul → Sep '26 | 44 → 43 → 42 → 42 | 50 → 52 → 51 → 51 | 34 / 15 / 48 (Sep) | Lowest series for Lula, consistent with its -2.4 house effect in the runoff |
| Ipsos-Ipec | Sep '25 → Dec '25 → Mar '26 → Jun '26 | 44 → 42 → 43 → 44 | 51 → 52 → 51 → 50 | 32 / 28 / 38 (Jun) | Trust in Lula 40% / distrust 56% (Mar) |
| BTG/Nexus · RTBD · CNT/MDA | Aug '26 · Aug '26 · Nov '25 | 48 · 45 · 48.1 | 49 · 51 · 48.8 | 35/—/43 · 27/33/39 · 34/29/36 | — |
| Economic indicator | Latest data | Electoral reading |
|---|---|---|
| IPCA (inflation) | Jul '26: +0.07% m/m; 4.44% over 12 months (below the ceiling after 2 months); food at home −1.14%. IPCA-15 Aug: −0.40% (4.24% over 12 months); food −0.57%; electricity −6.25% (Itaipu bonus) | Food deflation in Jun–Aug not yet converted into perception (49% say the economy got worse). Food had risen 7.4% cumulatively as of Aug '25 |
| Unemployment (PNAD) | May–Jul '26: 5.3% (lowest in the series); 103.3 million employed; 39.4 million in formal private jobs (records); informality 37.5% | An asset for the incumbent — but Nunes: 64% say their income rose and only 10% that it outpaced the cost of living |
| Real income | R$ 3,762; +3.3% y/y; −0.7% in the quarter; total income R$ 383.5 billion (+4.1%) | Modest and slowing real gains |
| Selic (policy rate) | 15% → 0.25-point cuts since Mar '26 → 14.00% (Aug 5); Copom Sep 15–16; Focus 13.75% at end-2026 | Slow easing cycle; effect on household credit still small |
| GDP | 2025: +2.3%. Q2 '26: +0.5% q/q, +2.0% y/y; household consumption −0.4% q/q; Focus 2026: 1.92% (falling) | A slowdown in consumption in the second half is the main economic risk for the incumbent |
| Consumer confidence (FGV) | Aug '26: 84.7 (−3.6), lowest since Nov 2022; 4th straight drop; expectations 86.1 | Indebted and delinquent households (FGV) — what is "felt" diverges from what is "measured" |
| Markets / exchange rate | USD/BRL R$ 5.09 (Sep 8); Ibovespa ≈188,000; foreign outflow of R$ 15.6 billion from B3 in Aug; JPMorgan downgraded Brazil to neutral (Aug 11) | "Election trade": stocks rise and the real strengthens when Flávio gains — Eurasia and Morgan Stanley read it as underpricing of risk |
| Fiscal | Gross debt 81.9% of GDP (Jun '26); primary deficit 1.19%; nominal 9.99% (Morgan Stanley) | Morgan Stanley: what matters is post-2026 fiscal credibility, not the winner |
| Sentiment | Quaest Aug: worse 49 / same 30 / better 19; next 12 months: improvement 42. Datafolha Jun: worse 45 / better 22; pessimists 26% (35 in Mar). Top problem: violence 33%, economy 15%, health 14%, corruption 14% | Security overtakes the economy as a concern — Flávio's turf (Garman: Lula has "low credibility on security and corruption") |
| Source | Date | P(Lula) | P(Flávio) | Note |
|---|---|---|---|---|
| Polymarket | Sep 17, 2026 | 45% | 55% | Stable since the Sep 15 STF session; volume US$ 150.7 million; normalized P(Lula) ≈ 45% |
| Polymarket | Sep 16, 2026 | 45% | 54% | After the STF session ended without a ruling on the merits (Dino requested more time to review); volume US$ 150.6 million; normalized P(Lula) ≈ 45% |
| Polymarket | Sep 15, 2026 | 45% | 52% | Day of the special STF session on the Moraes–Vorcaro report; cumulative volume US$ 150.1 million; normalized P(Lula) ≈ 46% |
| Polymarket | Sep 13, 2026 | 49% | 50.2% | Flip relative to Sep 8 (Lula 59%); cumulative volume US$ 149.4 million since the market opened (Sep 2025); repricing attributed to the STF/Federal Police leadership crisis and the partial lifting of secrecy in the Master case (Sep 9–10) |
| Kalshi | Sep 10, 2026 | ≈49% | ≈51% | Market headline: "Flávio Bolsonaro Hits 51% as Supreme Court Clash Rattles Lula" — attributes the flip to the "judiciary standoff" and to emergency fuel cuts. Before Sep 8: Lula 56%; first-round winner (Sep 8 reading): Lula 89%; 2nd place: Flávio 88%; Lula margin 0–5 pts 44%, 5–10 pts 38% |
| Polymarket/Kalshi (Sep 8, pre-crisis) | Sep 8, 2026 | 59% · 56% | 39.9% · 44% | Reading prior to the Sep 8–10 STF crisis, kept here for historical comparison; Predict.fun/Prediction Hunt average 57–58% |
| Eurasia Group (Garman) | Sep '25 → Apr '26 → Jul '26 → Aug '26 | 50-50 (leaning Lula) → 55% → 60% → ~60% | ~40% | May '26: 30% chance of another right-wing name in the runoff. Aug '26: "market underestimates Flávio"; clients who see Lula as the favorite went from 47% to 80% |
| XP Research | Jul '26 | Lula "slight favorite" | — | Election decided by 3–4% undecided, 69% of them women; rising abstention hurts Lula's coalition |
| Morgan Stanley (126 pp.) | Aug 25, 2026 | no P | — | 2027 scenarios: base GDP 0.8% / Selic 12.75 / USD 5.25; pessimistic 0.2% / 15.5% / 6.00 (Ibov −25%); optimistic 1.5% / 9.75% / 4.50; risks tilted to the pessimistic side |
| BTG Pactual · JPMorgan | Mar–Aug '26 | no P | — | BTG: portfolios by scenario (Lula: SUZB3, ITUB4, WEGE3; Flávio: BBAS3, XP, PETR4). JPM downgraded Brazilian stocks to neutral |
| "Brasil em Mapas" (Monte Carlo nowcast, 15 polls) | Sep 7, 2026 | 74% | 26% | Central 51.5 × 48.5 valid votes; no historical bias correction — compare with our model's 27% without correction |
| Panel of 6 AI models (OddsShopper) | Aug 6, 2026 | 68% | 23.8% | "1 in 3 chance that age, a campaign turnaround or consolidation changes the winner" |
| This dashboard | 25/10/2026 | model 17% · ensemble 18% | 83% · 82% | The only one with an explicit correction for 2006–22 bias and an estimated trend (Kalman smoother); Itaú, Bradesco BBI, Genial, Warren, Goldman, Barclays, the Economist and the FT did not publish a base case with a probability |
| Segment | Lula | Flávio | Source |
|---|---|---|---|
| Evangelicals (~30% of the electorate) | 29% | 62% | Datafolha Aug 18–20 (runoff); Atlas: 76% disapprove of Lula; Quaest first round 43 × 24 |
| Catholics | 54% | 36% | Datafolha Aug; Quaest first round 47 × 27 |
| Women · Men | 51% · 43% | 38% · 48% | Datafolha Aug; PoderData Sep: women 48 × 41; male disapproval 50 → 57 (Quaest Jul→Sep) |
| Northeast · Southeast · South · CW/N | 61 · 43 · 38 · 42 | 30 · 46 · 50 · 49 | Datafolha Aug. Quaest state polls (Apr): Flávio +26 RS, +20 PR, +13 GO/RJ, +12 SP; Lula +34 PE, +33 BA, +28 CE, +7 PA, +3 MG (tie) |
| Income up to 2 MW · 2–5 MW · >5 MW | majority · 37 · 39 | — · 51 · 54 | Datafolha Aug; Atlas: approval 55.6% up to R$ 2,000 |
| Independents / no party | 38.2% | 26% | AtlasIntel Aug (Cury 19.2%); Roman: "only 10% are truly undecided — disengaged, lower-middle class, more women, looking for something new" |
| Solid base ("firm vote") | 29–33% | 24% | Nunes/Quaest Sep 7; undecided voters want outsiders (20%) or moderates (17%) |
| Cycle | Runoff simulation in September | Result | Margin drift | Eve-of-election polls × result |
|---|---|---|---|---|
| 2006 | Lula ≈ +20 (Datafolha Sep pointed to a first-round win with 56% of valid votes) | Lula 60.8 × 39.2 (+21.7) | ≈ +2 | Datafolha 61 × 39: error +0.2 |
| 2010 | Dilma 55 × Serra 38 (Datafolha Sep 21–22; +17) | Dilma 56.1 × 43.9 (+12.1) | −5 | Datafolha 56 × 44 (0) · Ibope 55 × 45 (−1) |
| 2014 | Marina 50 × Dilma 40 (Aug); Dilma 49 × Aécio 41 (Sep 30; +8) | Dilma 51.6 × Aécio 48.4 (+3.3) | −5 (and the August leader did not even make the runoff) | Datafolha 52 × 48 (+0.7) · Ibope 53 × 47 (+2.2) |
| 2018 | Haddad 45 × Bolsonaro 39 (Datafolha Sep 26–28; +6 Haddad) | Bolsonaro 55.1 × 44.9 (+10.3) | −16 | Datafolha 45 × 55 (+0.3) · Ibope 46 × 54 (+2.3) · MDA (−3.3) · Vox 50 × 50 (+10.3) |
| 2022 | Lula 54 × Bolsonaro 38 (Datafolha Sep 20–22; +16); Sep 26–Oct 1: +5.3 to +18.3 | Lula 50.9 × 49.1 (+1.8) | −14 | Paraná +0.8 · MDA +2.2 · Datafolha +4 · Quaest/Atlas/PoderData/Ipespe +6 · Ipec +8 → average error +3.1 |
| 2026 | Lula -1.3 (nowcast Oct 25, 2026) · -2.0 two weeks ago | ? | model: drift N(0, 0.0) | Lula's break-even on the eve: +0.8 |
| Pollster | MAE (pp) | Average bias | Active in 2026 | Quality weight |
|---|---|---|---|---|
| Futura Inteligencia | 2.4 | -2.4 | yes | 0.85 |
| Datafolha | 3.4 | +3.4 | yes | 1.32 |
| Verita | 3.8 | -3.8 | yes | 0.76 |
| Paraná Pesquisas | 3.8 | -2.1 | yes | 0.78 |
| MDA | 3.9 | +1.5 | yes | 1.32 |
| Quaest | 4.0 | +4.0 | yes | 1.15 |
| Ibope | 4.5 | +4.5 | no | — |
| PoderData | 4.5 | +4.5 | yes | 1.12 |
| AtlasIntel | 4.5 | +4.5 | yes | 0.93 |
| Ipespe | 4.9 | +2.0 | yes | 0.93 |
| Ipec | 7.5 | +7.5 | no | — |
| Vox Populi | 7.6 | +7.6 | no | — |
| Brasmarket | 7.8 | -7.8 | no | — |
Runoff error has no fixed direction: pro-PT in 2014, 2022 and, narrowly, 2006; against the PT in 2010 and 2018 (average by cycle: 2006: +0.3, 2010: -0.1, 2014: +1.1, 2018: -1.2, 2022: +2.9). In the first rounds from 2010 to 2022, by contrast, the error always ran against the PT's main opponent (2010: +5.2, 2014: +10.5, 2018: +2.4, 2022: +5.8). That is why the discount for final runoff rounds is small (+0.9 ± 2.0) and the first-round one is larger (+3.4 ± 3.8); observed polling error in the first round for waves before Oct 4, and final runoff rounds from 2006–2022 plus 25% of that error for the new waves. In 2026, with the complete database (221 waves) and the anchor on the calibrated pollsters, the house effects statistically distinct from zero are: showing Lula better than the anchor, CNT/MDA (+3.9); showing Flávio better, Gerp (-7.7), Veritá (-5.3), Palver (-4.4), Futura (-3.8), PoderData (-2.4), Paraná Pesquisas (-2.0).
| Pollster | Election | Final poll (left × right, valid votes) | Result | Margin error | Source · verification |
|---|---|---|---|---|---|
| AtlasIntel | 2022 · runoff | 53.4 × 46.6 | 50.9 × 49.1 | +5.0 | source · yes |
| Brasmarket (outside the reference set) | 2022 · runoff | 47 × 53 | 50.9 × 49.1 | -7.8 | source · yes |
| Datafolha | 2022 · runoff | 52 × 48 | 50.9 × 49.1 | +2.2 | source · yes |
| Futura Inteligencia (outside the reference set) | 2022 · runoff | 49.7 × 50.3 | 50.9 × 49.1 | -2.4 | source · yes |
| Ipec | 2022 · runoff | 54 × 46 | 50.9 × 49.1 | +6.2 | source · yes |
| Ipespe | 2022 · runoff | 53 × 47 | 50.9 × 49.1 | +4.2 | source · yes |
| CNT/MDA | 2022 · runoff | 51.1 × 48.9 | 50.9 × 49.1 | +0.4 | source · yes |
| Paraná Pesquisas | 2022 · runoff | 50.4 × 49.6 | 50.9 × 49.1 | -1.0 | source · yes |
| PoderData | 2022 · runoff | 53 × 47 | 50.9 × 49.1 | +4.2 | source · yes |
| Quaest | 2022 · runoff | 52 × 48 | 50.9 × 49.1 | +2.2 | source · yes |
| Verita (outside the reference set) | 2022 · runoff | 49 × 51 | 50.9 × 49.1 | -3.8 | source · yes |
| AtlasIntel | 2022 · first round | 50.3 × 41.1 | 48.4 × 43.2 | +4.0 | source · yes |
| Datafolha | 2022 · first round | 50 × 36 | 48.4 × 43.2 | +8.8 | source · yes |
| Ipec | 2022 · first round | 51 × 37 | 48.4 × 43.2 | +8.8 | source · yes |
| Ipespe | 2022 · first round | 49 × 35 | 48.4 × 43.2 | +8.8 | source · yes |
| CNT/MDA | 2022 · first round | 48.3 × 39.7 | 48.4 × 43.2 | +3.4 | source · yes |
| Paraná Pesquisas | 2022 · first round | 47.1 × 40 | 48.4 × 43.2 | +1.9 | source · yes |
| PoderData | 2022 · first round | 48 × 38 | 48.4 × 43.2 | +4.8 | source · yes |
| Quaest | 2022 · first round | 49 × 38 | 48.4 × 43.2 | +5.8 | source · yes |
| Datafolha | 2018 · runoff | 45 × 55 | 44.9 × 55.1 | +0.3 | source · yes |
| Ibope | 2018 · runoff | 46 × 54 | 44.9 × 55.1 | +2.3 | source · yes |
| Ipespe | 2018 · runoff | 42 × 58 | 44.9 × 55.1 | -5.7 | source · yes |
| CNT/MDA | 2018 · runoff | 43.2 × 56.8 | 44.9 × 55.1 | -3.3 | source · search summary |
| Paraná Pesquisas | 2018 · runoff | 39.4 × 60.6 | 44.9 × 55.1 | -10.9 | source · yes |
| Vox Populi | 2018 · runoff | 50 × 50 | 44.9 × 55.1 | +10.3 | source · yes |
| Datafolha | 2018 · first round | 25 × 40 | 29.3 × 46.0 | +1.8 | source · yes |
| Ibope | 2018 · first round | 25 × 41 | 29.3 × 46.0 | +0.8 | source · yes |
| Ipespe | 2018 · first round | 25 × 41 | 29.3 × 46.0 | +0.8 | source · yes |
| CNT/MDA | 2018 · first round | 27.8 × 42.6 | 29.3 × 46.0 | +1.9 | source · search summary |
| Paraná Pesquisas | 2018 · first round | 25.6 × 40.9 | 29.3 × 46.0 | +1.4 | source · search summary |
| Vox Populi | 2018 · first round | 31 × 40 | 29.3 × 46.0 | +7.8 | source · yes |
| Datafolha | 2014 · runoff | 52 × 48 | 51.6 × 48.4 | +0.7 | source · yes |
| Ibope | 2014 · runoff | 53 × 47 | 51.6 × 48.4 | +2.7 | source · yes |
| CNT/MDA | 2014 · runoff | 49.7 × 50.3 | 51.6 × 48.4 | -3.9 | source · yes |
| Vox Populi | 2014 · runoff | 54 × 46 | 51.6 × 48.4 | +4.7 | source · yes |
| Datafolha | 2014 · first round | 44 × 26 | 41.6 × 33.5 | +10.0 | source · yes |
| Ibope | 2014 · first round | 46 × 27 | 41.6 × 33.5 | +11.0 | source · yes |
| CNT/MDA | 2014 · first round | 45.6 × 27 | 41.6 × 33.5 | +10.6 | source · yes; valid votes computed from totals 40.6/24.0 with 11% blank+undecided |
| Datafolha | 2010 · runoff | 56 × 44 | 56.0 × 44.0 | -0.1 | source · wikipedia |
| Datafolha | 2010 · first round | 50 × 31 | 46.9 × 32.6 | +4.7 | source · yes; 2nd source: en.wikipedia Opinion polling for the 2010 Brazilian presidential election |
| Ibope | 2010 · first round | 51 × 31 | 46.9 × 32.6 | +5.7 | source · yes; 2nd source: en.wikipedia Opinion polling for the 2010 Brazilian presidential election |
| Datafolha | 2006 · runoff | 61 × 39 | 60.8 × 39.2 | +0.3 | source · wikipedia |
| Datafolha (outside the reference set) | 2006 · first round | 50 × 38 | 48.6 × 41.6 | +5.0 | source · single source (Wikipedia gives 48 x 37 without saying whether these are valid votes); excluded from the calculation |
| Pollster | Sponsor | Fieldwork (end) | n | Method | Margin of error (95%) | Lula | Flávio | Blank/spoiled | Undec. | Valid-vote margin | Weight | Source |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Futura Inteligencia v2 | Apex/100% Cidades | 24/10/26 | 2,000 | phone | ±2.2 pp | 47.9 | 48.2 | 2.3 | 1.6 | -0.3 | 0.96 | source |
| PoderData | Aya | 24/10/26 | 4,000 | phone | ±1.5 pp | 47.0 | 49.0 | 3.0 | 1.0 | -2.1 | 1.73 | source |
| BTG/Nexus | BTG Pactual | 24/10/26 | 2,000 | phone | ±2.2 pp | 46.0 | 47.0 | 5.2 | 1.8 | -1.1 | 0.93 | source |
| Quaest | Globo/O Globo | 24/10/26 | 3,702 | in-person | ±1.6 pp | 45.0 | 46.0 | 8.0 | 1.0 | -1.1 | 1.56 | source |
| Datafolha | Folha/Globo | 24/10/26 | 4,050 | in-person | ±1.5 pp | 47.0 | 48.0 | 3.0 | 2.0 | -1.1 | 1.73 | source |
| Palver v3 | self-funded | 24/10/26 | 5,000 | online | ±1.4 pp | 49.0 | 50.0 | 1.0 | 0.0 | -1.0 | 0.89 | source |
| Gerp | self-funded | 24/10/26 | 2,400 | phone | ±2.0 pp | 46.0 | 49.0 | 1.0 | 3.0 | -3.2 | 1.12 | source |
| Colectta | Brazil Economy | 23/10/26 | 3,000 | in-person | ±1.8 pp | 46.5 | 46.0 | 4.4 | 3.1 | +0.5 | 1.28 | source |
| Verita | self-funded | 23/10/26 | 15,554 | phone | ±0.8 pp | 46.5 | 49.4 | 1.4 | 2.7 | -3.1 | 1.40 | source |
| AtlasIntel | Bloomberg | 23/10/26 | 4,990 | online | ±1.4 pp | 48.4 | 49.9 | 1.7 | — | -1.5 | 1.15 | source |
| MDA v2 | CNT | 23/10/26 | 2,032 | in-person | ±2.2 pp | 45.0 | 46.3 | 5.8 | 2.9 | -1.4 | 0.90 | source |
| Instituto Franca | self-funded | 23/10/26 | 1,537 | not reported | ±2.5 pp | 43.1 | 45.6 | 7.4 | 3.9 | -2.8 | 0.68 | source |
| Alfa Inteligencia | self-funded | 22/10/26 | 2,700 | in-person | ±1.9 pp | 46.0 | 49.9 | 2.0 | 2.1 | -4.1 | 1.06 | source |
| Datafolha | Folha/Globo | 22/10/26 | 2,520 | in-person | ±2.0 pp | 46.0 | 47.0 | 5.0 | 2.0 | -1.1 | 1.07 | source |
| Vox Brasil | self-funded | 22/10/26 | 2,100 | in-person | ±2.1 pp | 45.4 | 44.3 | 6.0 | 4.3 | +1.2 | 0.73 | source |
| Real Time Big Data | self-funded | 22/10/26 | 2,000 | phone | ±2.2 pp | 45.0 | 49.0 | 5.0 | 1.0 | -4.3 | 0.88 | source |
| Verita | self-funded | 22/10/26 | 15,554 | phone | ±0.8 pp | 47.5 | 50.2 | 1.4 | 0.9 | -2.8 | 1.37 | source |
| Ipespe | Abrapel | 22/10/26 | 1,500 | phone | ±2.5 pp | 47.0 | 48.0 | 3.0 | 2.0 | -1.1 | 0.69 | source |
| DataTrends | self-funded | 21/10/26 | 2,000 | phone | ±2.2 pp | 46.0 | 50.0 | 4.0 | 1.0 | -4.2 | 0.87 | source |
| Indexa | Broadcast/Estadão | 21/10/26 | 2,000 | phone | ±2.2 pp | 44.1 | 45.2 | 9.1 | 1.6 | -1.2 | 0.82 | source |
| PoderData | Aya | 21/10/26 | 4,000 | phone | ±1.5 pp | 48.0 | 49.0 | 3.0 | 1.0 | -1.0 | 1.58 | source |
| Gerp | self-funded | 21/10/26 | 2,400 | phone | ±2.0 pp | 45.0 | 50.0 | 2.0 | 2.0 | -5.3 | 1.01 | source |
| Futura Inteligencia v2 | Apex/100% Cidades | 20/10/26 | 2,000 | phone | ±2.2 pp | 46.8 | 48.9 | 2.0 | 2.3 | -2.2 | 0.84 | source |
| Quaest | Globo/O Globo | 20/10/26 | 2,154 | in-person | ±2.1 pp | 44.0 | 45.0 | 9.0 | 2.0 | -1.1 | 0.84 | source |
| Palver v3 | self-funded | 20/10/26 | 5,000 | online | ±1.4 pp | 48.0 | 50.0 | 1.0 | 0.0 | -2.0 | 0.77 | source |
| Instituto Franca | self-funded | 20/10/26 | 3,000 | phone | ±1.8 pp | 39.6 | 41.0 | 10.6 | 8.8 | -1.6 | 1.03 | source |
| Alfa Inteligencia | self-funded | 19/10/26 | 2,700 | in-person | ±1.9 pp | 45.9 | 48.8 | 3.5 | 1.8 | -3.1 | 0.95 | source |
| Real Time Big Data | self-funded | 19/10/26 | 2,000 | phone | ±2.2 pp | 45.0 | 48.0 | 5.0 | 2.0 | -3.2 | 0.79 | source |
| Gerp | self-funded | 19/10/26 | 2,400 | phone | ±2.0 pp | 45.0 | 50.0 | 2.0 | 3.0 | -5.3 | 0.95 | source |
| Meio/Ideia | Canal Meio | 19/10/26 | 1,500 | phone | ±2.5 pp | 46.0 | 48.0 | 3.4 | 2.6 | -2.1 | 0.62 | source |
| Verita | self-funded | 19/10/26 | 15,554 | phone | ±0.8 pp | 48.2 | 49.7 | 1.4 | 0.7 | -1.5 | 1.24 | source |
| AtlasIntel | Bloomberg | 18/10/26 | 4,998 | online | ±1.4 pp | 47.6 | 49.8 | 2.6 | — | -2.3 | 0.97 | source |
| PoderData | Aya | 18/10/26 | 3,000 | phone | ±1.8 pp | 46.0 | 49.0 | 4.0 | 2.0 | -3.2 | 1.11 | source |
| BTG/Nexus | BTG Pactual | 18/10/26 | 2,002 | phone | ±2.2 pp | 45.9 | 46.5 | 4.7 | 2.9 | -0.6 | 0.76 | source |
| DataTrends | self-funded | 17/10/26 | 2,000 | phone | ±2.2 pp | 46.0 | 49.0 | 4.0 | 1.0 | -3.2 | 0.76 | source |
| Futura Inteligencia | Apex/100% Cidades | 17/10/26 | 2,000 | phone | ±2.2 pp | 47.5 | 48.3 | 2.4 | 1.9 | -0.8 | 0.76 | source |
| Vox Brasil | self-funded | 17/10/26 | 2,100 | in-person | ±2.1 pp | 46.4 | 44.6 | 5.0 | 3.9 | +2.0 | 0.63 | source |
| Verita | self-funded | 16/10/26 | 15,554 | phone | ±0.8 pp | 47.7 | 49.7 | 1.5 | 1.1 | -2.1 | 1.12 | source |
| Gerp | self-funded | 16/10/26 | 2,400 | phone | ±2.0 pp | 45.0 | 50.0 | 2.0 | 3.0 | -5.3 | 0.86 | source |
| MDA v2 | CNT | 15/10/26 | 2,014 | in-person | ±2.2 pp | 45.9 | 46.8 | 4.9 | 2.4 | -1.0 | 0.70 | source |
| PoderData | Aya | 15/10/26 | 3,000 | phone | ±1.8 pp | 47.0 | 47.0 | 4.0 | 2.0 | 0.0 | 1.00 | source |
| Futura Inteligencia | Apex/100% Cidades | 15/10/26 | 2,000 | phone | ±2.2 pp | 47.5 | 49.0 | 1.5 | 2.1 | -1.6 | 0.72 | source |
| Ipespe | Abrapel | 15/10/26 | 1,500 | phone | ±2.5 pp | 46.0 | 48.0 | 3.0 | 3.0 | -2.1 | 0.54 | source |
| Indexa | Broadcast/Estadão | 14/10/26 | 2,000 | phone | ±2.2 pp | 42.0 | 44.0 | 12.0 | 2.0 | -2.3 | 0.63 | source |
| Datafolha | Folha/Globo | 14/10/26 | 2,520 | in-person | ±2.0 pp | 45.0 | 47.0 | 6.0 | 2.0 | -2.2 | 0.82 | source |
| Palver v3 | self-funded | 13/10/26 | 5,000 | online | ±1.4 pp | 49.0 | 50.0 | 1.0 | 0.0 | -1.0 | 0.62 | source |
| Quaest | Globo/O Globo | 13/10/26 | 2,154 | in-person | ±2.1 pp | 44.0 | 46.0 | 9.0 | 1.0 | -2.2 | 0.67 | source |
| Gerp | self-funded | 12/10/26 | 2,400 | phone | ±2.0 pp | 44.0 | 53.0 | 1.0 | 3.0 | -9.3 | 0.77 | source |
| PoderData | Aya | 12/10/26 | 3,000 | phone | ±1.8 pp | 46.0 | 48.0 | 5.0 | 2.0 | -2.1 | 0.90 | source |
| BTG/Nexus | BTG Pactual | 12/10/26 | 2,002 | phone | ±2.2 pp | 45.0 | 48.0 | 5.0 | 2.0 | -3.2 | 0.63 | source |
| Futura Inteligencia | Apex/100% Cidades | 10/10/26 | 2,000 | phone | ±2.2 pp | 46.6 | 50.0 | 1.7 | 1.7 | -3.5 | 0.61 | source |
| DataTrends | self-funded | 09/10/26 | 2,000 | phone | ±2.2 pp | 44.0 | 50.0 | 5.0 | 1.0 | -6.4 | 0.58 | source |
| Ipespe | self-funded | 08/10/26 | 1,500 | phone | ±2.5 pp | 44.0 | 49.0 | 5.0 | 3.0 | -5.4 | 0.42 | source |
| AtlasIntel | Bloomberg | 08/10/26 | 5,026 | online | ±1.4 pp | 45.7 | 51.1 | 3.2 | — | -5.6 | 0.70 | source |
| PoderData | Aya | 07/10/26 | 3,000 | phone | ±1.8 pp | 44.0 | 49.0 | 5.0 | 2.0 | -5.4 | 0.76 | source |
| Datafolha | Folha/Globo | 07/10/26 | 2,520 | in-person | ±2.0 pp | 45.0 | 49.0 | 5.0 | 1.0 | -4.3 | 0.66 | source |
| Vox Brasil | self-funded | 07/10/26 | 2,100 | in-person | ±2.1 pp | 44.2 | 42.7 | 3.4 | 9.7 | +1.7 | 0.43 | source |
| Futura Inteligencia | Apex/100% Cidades | 03/10/26 | 2,000 | phone | ±2.2 pp | 45.1 | 48.0 | 5.7 | 1.2 | -3.1 | 0.47 | source |
| Quaest | Globo/O Globo | 03/10/26 | 3,702 | in-person | ±1.6 pp | 42.0 | 44.0 | 13.0 | 1.0 | -2.3 | 0.74 | source |
| Datafolha | Folha/Globo | 03/10/26 | 4,006 | in-person | ±1.5 pp | 47.0 | 46.0 | 6.0 | 2.0 | +1.1 | 0.84 | source |
| Palver | self-funded | 03/10/26 | 5,000 | online | ±1.4 pp | 44.0 | 49.0 | 6.0 | 1.0 | -5.4 | 0.40 | source |
| AtlasIntel | self-funded | 02/10/26 | 4,945 | online | ±1.4 pp | 47.6 | 47.4 | 5.0 | — | +0.2 | 0.56 | source |
| Instituto Franca | self-funded | 02/10/26 | 2,000 | not reported | ±2.2 pp | 45.9 | 45.0 | 3.5 | 5.6 | +1.0 | 0.44 | source |
| MDA | CNT | 02/10/26 | 2,007 | in-person | ±2.2 pp | 47.3 | 43.1 | 7.6 | 1.9 | +4.6 | 0.44 | source |
| PoderData | Aya | 02/10/26 | 4,000 | phone | ±1.5 pp | 46.0 | 46.0 | 7.0 | 1.0 | 0.0 | 0.81 | source |
| Gerp | self-funded | 02/10/26 | 2,400 | phone | ±2.0 pp | 44.0 | 49.0 | 6.0 | 1.0 | -5.4 | 0.53 | source |
| Datafolha | Folha/Globo | 01/10/26 | 2,506 | in-person | ±2.0 pp | 48.0 | 45.0 | 6.0 | 1.0 | +3.2 | 0.53 | source |
| Vox Brasil | self-funded | 01/10/26 | 2,100 | not reported | ±2.1 pp | 45.2 | 48.2 | 2.5 | 4.1 | -3.2 | 0.38 | source |
| Real Time Big Data | CNN | 30/09/26 | 2,000 | phone | ±2.2 pp | 45.0 | 46.0 | 5.0 | 4.0 | -1.1 | 0.41 | source |
| DataTrends | self-funded | 30/09/26 | 2,000 | phone | ±2.2 pp | 45.0 | 45.0 | 7.0 | 3.0 | 0.0 | 0.41 | source |
| Futura Inteligencia | Apex/100% Cidades | 29/09/26 | 2,000 | phone | ±2.2 pp | 43.5 | 49.0 | 6.2 | 1.3 | -5.9 | 0.41 | source |
| Indexa | Broadcast/Estadão | 29/09/26 | 2,000 | phone | ±2.2 pp | 43.0 | 42.0 | 7.0 | 4.0 | +1.2 | 0.38 | source |
| Vox Brasil | self-funded | 28/09/26 | 2,100 | in-person | ±2.1 pp | 44.7 | 45.2 | 6.8 | 3.3 | -0.6 | 0.33 | source |
| Gerp | self-funded | 28/09/26 | 2,400 | phone | ±2.0 pp | 43.0 | 50.0 | 5.0 | 1.0 | -7.5 | 0.47 | source |
| Meio/Ideia | Canal Meio | 28/09/26 | 2,000 | phone | ±2.2 pp | 48.5 | 48.0 | 2.1 | 1.5 | +0.5 | 0.41 | source |
| AtlasIntel | Bloomberg | 28/09/26 | 5,005 | online | ±1.4 pp | 47.6 | 47.7 | 4.8 | — | -0.1 | 0.49 | source |
| Palver | self-funded | 27/09/26 | 5,000 | online | ±1.4 pp | 45.0 | 47.0 | 7.0 | 1.0 | -2.2 | 0.31 | source |
| BTG/Nexus | BTG | 27/09/26 | 2,000 | phone | ±2.2 pp | 46.0 | 44.0 | 8.0 | 1.0 | +2.2 | 0.37 | source |
| Quaest | Globo/O Globo | 27/09/26 | 2,004 | in-person | ±2.2 pp | 42.0 | 42.0 | 13.0 | 3.0 | 0.0 | 0.35 | source |
| PoderData | Aya | 23/09/26 | 3,000 | phone | ±1.8 pp | 45.0 | 46.0 | — | — | -1.1 | 0.47 | source |
| Real Time Big Data | — | 23/09/26 | 2,000 | phone | ±2.2 pp | 44.0 | 45.0 | 8.0 | 3.0 | -1.1 | 0.32 | source |
| Datafolha | Folha/Globo | 23/09/26 | 2,002 | in-person | ±2.2 pp | 47.0 | 45.0 | 7.0 | 1.0 | +2.2 | 0.33 | source |
| Futura Inteligencia | Apex | 23/09/26 | 2,000 | phone | ±2.2 pp | 43.7 | 49.4 | 5.6 | 1.3 | -6.1 | 0.34 | source |
| Palver | self-funded | 23/09/26 | 5,000 | online | ±1.4 pp | 45.0 | 48.0 | 6.0 | 1.0 | -3.2 | 0.27 | source |
| Alfa Inteligencia | TMC | 23/09/26 | 2,700 | in-person | ±1.9 pp | 43.0 | 43.0 | 7.0 | 7.0 | 0.0 | 0.37 | source |
| AtlasIntel | Bloomberg | 22/09/26 | 5,015 | online | ±1.4 pp | 47.7 | 47.4 | 4.9 | — | +0.3 | 0.40 | source |
| American Analytics | Times Brasil/CNBC | 20/09/26 | 2,000 | not reported | ±2.2 pp | 43.0 | 43.0 | 6.0 | 8.0 | 0.0 | 0.28 | source |
| Quaest | Globo/O Globo | 20/09/26 | 2,004 | in-person | ±2.2 pp | 41.0 | 42.0 | 14.0 | 3.0 | -1.2 | 0.28 | source |
| BTG/Nexus | BTG | 20/09/26 | 2,006 | phone | ±2.2 pp | 46.0 | 45.0 | 7.0 | 1.0 | +1.1 | 0.30 | source |
| Palver | self-funded | 18/09/26 | 5,000 | online | ±1.4 pp | 43.0 | 47.0 | 10.0 | — | -4.4 | 0.18 | source |
| PoderData | Aya | 16/09/26 | 3,000 | phone | ±1.8 pp | 44.0 | 46.0 | 8.0 | 2.0 | -2.2 | 0.37 | source |
| Gerp | self-funded | 16/09/26 | 2,400 | phone | ±2.0 pp | 43.0 | 50.0 | 6.0 | 1.0 | -7.5 | 0.32 | source |
| Datafolha | Folha/Globo | 16/09/26 | 2,002 | in-person | ±2.2 pp | 46.0 | 44.0 | 8.0 | 1.0 | +2.2 | 0.26 | source |
| AtlasIntel | Bloomberg | 16/09/26 | 5,018 | online | ±1.4 pp | 46.8 | 47.2 | 6.0 | — | -0.4 | 0.33 | source |
| Futura Inteligencia | Apex | 15/09/26 | 2,000 | phone | ±2.2 pp | 43.7 | 48.1 | 6.5 | 1.7 | -4.8 | 0.26 | source |
| DataTrends | self-funded | 14/09/26 | 2,000 | phone | ±2.2 pp | 44.0 | 45.0 | 8.0 | 3.0 | -1.1 | 0.24 | source |
| Quaest | Globo/O Globo | 13/09/26 | 2,004 | in-person | ±2.2 pp | 40.0 | 42.0 | 13.0 | 5.0 | -2.4 | 0.22 | source |
| MDA | CNT | 13/09/26 | 2,002 | in-person | ±2.2 pp | 47.3 | 40.0 | 10.2 | 2.5 | +8.4 | 0.23 | source |
| Indexa | Broadcast/Estadão | 13/09/26 | 2,000 | phone | ±2.2 pp | 43.0 | 42.0 | — | — | +1.2 | 0.22 | source |
| BTG/Nexus | BTG | 13/09/26 | 2,003 | phone | ±2.2 pp | 47.0 | 46.0 | 7.0 | — | +1.1 | 0.24 | source |
| Datafolha | Folha/Globo | 10/09/26 | 2,002 | in-person | ±2.2 pp | 46.0 | 44.0 | — | — | +2.2 | 0.21 | source |
| Futura Inteligencia | Apex | 10/09/26 | 2,000 | phone | ±2.2 pp | 45.0 | 45.4 | 7.1 | 2.5 | -0.4 | 0.21 | source |
| PoderData | Aya | 09/09/26 | 3,000 | phone | ±1.8 pp | 45.0 | 47.0 | — | — | -2.2 | 0.30 | source |
| AtlasIntel | Bloomberg | 09/09/26 | 5,000 | online | ±1.4 pp | 46.2 | 46.4 | 7.4 | — | -0.2 | 0.26 | source |
| Gerp | self-funded | 08/09/26 | 2,400 | phone | ±2.0 pp | 40.0 | 47.0 | 11.0 | 3.0 | -8.0 | 0.23 | source |
| Palver | self-funded | 07/09/26 | 5,000 | online | ±1.4 pp | 44.0 | 46.0 | — | — | -2.2 | 0.15 | source |
| BTG/Nexus | BTG | 07/09/26 | 2,002 | phone | ±2.2 pp | 45.0 | 46.0 | — | — | -1.1 | 0.19 | source |
| Meio/Ideia | Canal Meio | 07/09/26 | 1,500 | phone | ±2.5 pp | 46.0 | 46.0 | 3.5 | 4.5 | 0.0 | 0.15 | source |
| Quaest | Genial | 06/09/26 | 2,004 | in-person | ±2.2 pp | 41.0 | 41.0 | — | — | 0.0 | 0.17 | source |
| Verita | self-funded | 04/09/26 | 3,804 | not reported | ±1.6 pp | 43.1 | 48.5 | — | — | -5.9 | 0.23 | source |
| PoderData | Aya | 02/09/26 | 3,000 | phone | ±1.8 pp | 44.0 | 45.0 | — | — | -1.1 | 0.23 | source |
| Datafolha | Folha/Globo | 02/09/26 | 2,002 | in-person | ±2.2 pp | 46.0 | 44.0 | 9.0 | 2.0 | +2.2 | 0.16 | source |
| Quaest | Genial | 01/09/26 | 2,004 | in-person | ±2.2 pp | 42.0 | 41.0 | 13.0 | 4.0 | +1.2 | 0.15 | source |
| Futura Inteligencia | Apex | 01/09/26 | 2,000 | phone | ±2.2 pp | 45.6 | 45.2 | 7.2 | 2.0 | +0.4 | 0.16 | source |
| Real Time Big Data | CNN | 31/08/26 | 2,000 | phone | ±2.2 pp | 44.0 | 44.0 | 9.0 | 3.0 | 0.0 | 0.15 | source |
| BTG/Nexus | BTG | 30/08/26 | 2,005 | phone | ±2.2 pp | 46.0 | 45.0 | — | — | +1.1 | 0.15 | source |
| AtlasIntel | Bloomberg | 30/08/26 | 5,014 | online | ±1.4 pp | 47.1 | 42.6 | 10.3 | — | +5.0 | 0.18 | source |
| Vox Brasil | self-funded | 27/08/26 | 2,100 | in-person | ±2.1 pp | 44.5 | 45.1 | — | — | -0.7 | 0.12 | source |
| PoderData | Aya | 26/08/26 | 2,400 | phone | ±2.0 pp | 45.0 | 44.0 | — | — | +1.1 | 0.15 | source |
| Gerp | self-funded | 25/08/26 | 2,400 | phone | ±2.0 pp | 42.0 | 47.0 | — | — | -5.6 | 0.15 | source |
| BTG/Nexus | BTG | 23/08/26 | 2,006 | phone | ±2.2 pp | 46.0 | 45.0 | — | — | +1.1 | 0.12 | source |
| Indexa | Broadcast/Estadão | 23/08/26 | 2,000 | phone | ±2.2 pp | 46.0 | 41.0 | — | — | +5.7 | 0.11 | source |
| Datafolha | Folha/Globo | 20/08/26 | 2,058 | in-person | ±2.2 pp | 47.0 | 43.0 | 9.0 | 2.0 | +4.4 | 0.11 | source |
| Verita | self-funded | 20/08/26 | 3,840 | phone | ±1.6 pp | 42.0 | 47.3 | 6.1 | 4.6 | -5.9 | 0.14 | source |
| BTG/Nexus | BTG | 16/08/26 | 2,003 | phone | ±2.2 pp | 47.0 | 44.0 | — | — | +3.3 | 0.09 | source |
| Quaest | Genial | 13/08/26 | 2,004 | in-person | ±2.2 pp | 43.0 | 40.0 | — | — | +3.6 | 0.08 | source |
| PoderData | Aya | 12/08/26 | 2,400 | phone | ±2.0 pp | 46.0 | 45.0 | — | — | +1.1 | 0.10 | source |
| Gerp | self-funded | 10/08/26 | 2,400 | phone | ±2.0 pp | 43.0 | 45.0 | — | — | -2.3 | 0.09 | source |
| MDA | CNT | 09/08/26 | 2,002 | in-person | ±2.2 pp | 48.0 | 39.1 | 10.6 | 2.3 | +10.2 | 0.07 | source |
| BTG/Nexus | BTG | 09/08/26 | 2,001 | phone | ±2.2 pp | 47.0 | 44.0 | — | — | +3.3 | 0.07 | source |
| Palver | self-funded | 07/08/26 | 5,000 | online | ±1.4 pp | 46.0 | 46.0 | — | — | 0.0 | 0.04 | source |
| Futura Inteligencia | Apex | 07/08/26 | 2,000 | phone | ±2.2 pp | 46.5 | 44.0 | — | — | +2.8 | 0.07 | source |
| Meio/Ideia | Canal Meio | 03/08/26 | 1,500 | phone | ±2.5 pp | 48.5 | 43.0 | — | — | +6.0 | 0.05 | source |
| Quaest | Genial | 03/08/26 | 2,004 | in-person | ±2.2 pp | 44.0 | 39.0 | 13.0 | 4.0 | +6.0 | 0.06 | source |
| BTG/Nexus | BTG | 02/08/26 | 2,002 | phone | ±2.2 pp | 46.0 | 45.0 | — | — | +1.1 | 0.06 | source |
| Alfa Inteligencia | TMC | 28/07/26 | 2,700 | not reported | ±1.9 pp | 48.0 | 41.0 | — | — | +7.9 | 0.06 | source |
| PoderData | Aya | 28/07/26 | 2,400 | phone | ±2.0 pp | 46.0 | 43.0 | — | — | +3.4 | 0.06 | source |
| Vox Brasil | self-funded | 28/07/26 | 2,100 | not reported | ±2.1 pp | 47.5 | 41.1 | — | — | +7.2 | 0.04 | source |
| AtlasIntel | Bloomberg | 27/07/26 | 5,021 | online | ±1.4 pp | 49.2 | 42.9 | 7.9 | — | +6.8 | 0.06 | source |
| BTG/Nexus | BTG | 26/07/26 | 2,004 | phone | ±2.2 pp | 47.0 | 43.0 | — | — | +4.4 | 0.05 | source |
| Datafolha | Folha/Globo | 24/07/26 | 2,004 | in-person | ±2.2 pp | 48.0 | 43.0 | — | — | +5.5 | 0.04 | source |
| Real Time Big Data | CNN | 20/07/26 | 2,000 | phone | ±2.2 pp | 45.0 | 42.0 | — | — | +3.4 | 0.04 | source |
| Indexa | self-funded | 19/07/26 | 2,000 | phone | ±2.2 pp | 46.0 | 39.0 | — | — | +8.2 | 0.04 | source |
| Gerp | self-funded | 17/07/26 | 2,000 | not reported | ±2.2 pp | 45.0 | 46.0 | — | — | -1.1 | 0.04 | source |
| PoderData | Aya | 15/07/26 | 2,400 | phone | ±2.0 pp | 45.0 | 43.0 | — | — | +2.3 | 0.04 | source |
| Quaest | Genial | 13/07/26 | 2,004 | in-person | ±2.2 pp | 45.0 | 37.0 | — | — | +9.8 | 0.03 | source |
| BTG/Nexus | BTG | 12/07/26 | 2,003 | phone | ±2.2 pp | 47.0 | 44.0 | — | — | +3.3 | 0.03 | source |
| Futura Inteligencia | Apex | 11/07/26 | 2,000 | not reported | ±2.2 pp | 46.3 | 46.1 | — | — | +0.2 | 0.03 | source |
| Gerp | self-funded | 07/07/26 | 2,000 | not reported | ±2.2 pp | 42.0 | 45.0 | — | — | -3.4 | 0.02 | source |
| Meio/Ideia | Canal Meio | 06/07/26 | 2,000 | phone | ±2.2 pp | 45.0 | 40.0 | — | — | +5.9 | 0.02 | source |
| AtlasIntel | Bloomberg | 30/06/26 | 4,999 | online | ±1.4 pp | 48.8 | 42.3 | — | — | +7.1 | 0.02 | source |
| BTG/Nexus | BTG | 28/06/26 | 2,009 | phone | ±2.2 pp | 47.0 | 44.0 | — | — | +3.3 | 0.02 | source |
| Vox Brasil | self-funded | 25/06/26 | 2,100 | not reported | ±2.1 pp | 45.3 | 42.8 | — | — | +2.8 | 0.01 | source |
| PoderData | Aya | 24/06/26 | 2,400 | phone | ±2.0 pp | 46.0 | 43.0 | — | — | +3.4 | 0.02 | source |
| Indexa | self-funded | 20/06/26 | 2,000 | phone | ±2.2 pp | 47.0 | 40.0 | 9.0 | 4.0 | +8.0 | 0.01 | source |
| Gerp | self-funded | 20/06/26 | 2,000 | not reported | ±2.2 pp | 40.0 | 42.0 | — | — | -2.4 | 0.01 | source |
| Datafolha | Folha/Globo | 19/06/26 | 2,004 | in-person | ±2.2 pp | 47.0 | 43.0 | — | — | +4.4 | 0.01 | source |
| American Analytics | self-funded | 15/06/26 | 2,000 | not reported | ±2.2 pp | 45.0 | 40.0 | — | — | +5.9 | 0.01 | source |
| MDA | CNT | 14/06/26 | 2,002 | in-person | ±2.2 pp | 49.3 | 36.8 | — | — | +14.5 | 0.01 | source |
| BTG/Nexus | BTG | 14/06/26 | 2,017 | phone | ±2.2 pp | 49.0 | 43.0 | — | — | +6.5 | 0.01 | source |
| Futura Inteligencia | Apex | 12/06/26 | 2,000 | not reported | ±2.2 pp | 48.1 | 42.9 | — | — | +5.7 | 0.01 | source |
| Alfa Inteligencia | self-funded | 10/06/26 | 1,400 | not reported | ±2.6 pp | 44.0 | 41.0 | — | — | +3.5 | 0.01 | source |
| Quaest | Genial | 08/06/26 | 2,004 | in-person | ±2.2 pp | 44.0 | 38.0 | — | — | +7.3 | 0.01 | source |
| Vox Brasil | self-funded | 06/06/26 | 2,100 | not reported | ±2.1 pp | 47.8 | 41.3 | — | — | +7.3 | 0.01 | source |
| Gerp | self-funded | 05/06/26 | 2,000 | not reported | ±2.2 pp | 39.1 | 44.7 | — | — | -6.7 | 0.01 | source |
| Real Time Big Data | CNN | 30/05/26 | 2,000 | phone | ±2.2 pp | 45.0 | 40.0 | — | — | +5.9 | 0.01 | source |
| PoderData | Aya | 28/05/26 | 2,400 | phone | ±2.0 pp | 46.0 | 42.0 | — | — | +4.5 | 0.01 | source |
| Meio/Ideia | Canal Meio | 27/05/26 | 1,500 | phone | ±2.5 pp | 46.5 | 41.4 | — | — | +5.8 | 0.01 | source |
| Verita | self-funded | 26/05/26 | 3,330 | not reported | ±1.7 pp | 46.8 | 53.2 | — | — | -6.4 | 0.01 | source |
| Indexa | self-funded | 24/05/26 | 2,000 | phone | ±2.2 pp | 46.0 | 41.0 | — | — | +5.7 | 0.01 | source |
| BTG/Nexus | BTG | 24/05/26 | 2,045 | phone | ±2.2 pp | 47.0 | 43.0 | — | — | +4.4 | 0.01 | source |
| Gerp | self-funded | 21/05/26 | 2,000 | not reported | ±2.2 pp | 44.0 | 47.0 | — | — | -3.3 | 0.01 | source |
| Datafolha | Folha/Globo | 21/05/26 | 2,004 | in-person | ±2.2 pp | 47.0 | 43.0 | — | — | +4.4 | 0.01 | source |
| Futura Inteligencia | Apex | 20/05/26 | 2,000 | not reported | ±2.2 pp | 47.7 | 42.2 | — | — | +6.1 | 0.01 | source |
| Vox Brasil | self-funded | 19/05/26 | 2,100 | not reported | ±2.1 pp | 46.8 | 38.1 | — | — | +10.2 | 0.00 | source |
| AtlasIntel | Bloomberg | 18/05/26 | 5,032 | online | ±1.4 pp | 48.9 | 41.8 | — | — | +7.8 | 0.01 | source |
| Vetor | self-funded | 18/05/26 | 9,000 | not reported | ±1.0 pp | 50.7 | 38.3 | — | — | +13.9 | 0.00 | source |
| Datafolha | Folha/Globo | 14/05/26 | 2,004 | in-person | ±2.2 pp | 45.0 | 45.0 | — | — | 0.0 | 0.00 | source |
| Vox Brasil | self-funded | 12/05/26 | 2,100 | not reported | ±2.1 pp | 40.2 | 43.8 | — | — | -4.3 | 0.00 | source |
| Gerp | self-funded | 12/05/26 | 2,000 | not reported | ±2.2 pp | 43.0 | 50.0 | — | — | -7.5 | 0.00 | source |
| Quaest | Genial | 11/05/26 | 2,004 | in-person | ±2.2 pp | 42.0 | 41.0 | — | — | +1.2 | 0.00 | source |
| Futura Inteligencia | Apex | 08/05/26 | 2,000 | not reported | ±2.2 pp | 44.4 | 46.9 | — | — | -2.7 | 0.00 | source |
| Meio/Ideia | Canal Meio | 05/05/26 | 1,500 | phone | ±2.5 pp | 44.7 | 45.3 | — | — | -0.7 | 0.00 | source |
| Real Time Big Data | CNN | 04/05/26 | 2,000 | phone | ±2.2 pp | 43.0 | 44.0 | — | — | -1.1 | 0.00 | source |
| AtlasIntel | Bloomberg | 27/04/26 | 5,008 | online | ±1.4 pp | 47.5 | 47.8 | — | — | -0.3 | 0.00 | source |
| BTG/Nexus | BTG | 26/04/26 | 2,028 | phone | ±2.2 pp | 46.0 | 45.0 | — | — | +1.1 | 0.00 | source |
| Quaest | Genial | 13/04/26 | 2,004 | in-person | ±2.2 pp | 40.0 | 42.0 | — | — | -2.4 | 0.00 | source |
| MDA | CNT | 12/04/26 | 2,002 | in-person | ±2.2 pp | 44.9 | 40.2 | — | — | +5.5 | 0.00 | source |
| Futura Inteligencia | Apex | 11/04/26 | 2,000 | not reported | ±2.2 pp | 42.6 | 48.0 | — | — | -6.0 | 0.00 | source |
| Datafolha | Folha/Globo | 09/04/26 | 2,004 | in-person | ±2.2 pp | 45.0 | 46.0 | — | — | -1.1 | 0.00 | source |
| Meio/Ideia | Canal Meio | 07/04/26 | 1,500 | phone | ±2.5 pp | 45.5 | 45.8 | — | — | -0.3 | 0.00 | source |
| Verita | self-funded | 04/04/26 | 40,500 | not reported | ±0.5 pp | 38.4 | 42.8 | — | — | -5.4 | 0.00 | source |
| BTG/Nexus | BTG | 29/03/26 | 2,000 | phone | ±2.2 pp | 46.0 | 46.0 | — | — | 0.0 | 0.00 | source |
| Paraná Pesquisas | self-funded | 28/03/26 | 2,080 | in-person | ±2.1 pp | 44.1 | 45.2 | 6.2 | 4.5 | -1.2 | 0.00 | source |
| Gerp | self-funded | 25/03/26 | 2,000 | not reported | ±2.2 pp | 45.0 | 48.0 | — | — | -3.2 | 0.00 | source |
| AtlasIntel | Bloomberg | 23/03/26 | 5,028 | online | ±1.4 pp | 46.6 | 47.6 | — | — | -1.1 | 0.00 | source |
| Alfa Inteligencia | self-funded | 11/03/26 | 1,000 | not reported | ±3.1 pp | 41.0 | 38.0 | — | — | +3.8 | 0.00 | source |
| Meio/Ideia | Canal Meio | 10/03/26 | 1,500 | phone | ±2.5 pp | 47.0 | 45.0 | — | — | +2.2 | 0.00 | source |
| Quaest | Genial | 09/03/26 | 2,004 | in-person | ±2.2 pp | 41.0 | 41.0 | — | — | 0.0 | 0.00 | source |
| Futura Inteligencia | Apex | 06/03/26 | 2,000 | not reported | ±2.2 pp | 40.5 | 48.8 | — | — | -9.3 | 0.00 | source |
| Datafolha | Folha/Globo | 05/03/26 | 2,004 | in-person | ±2.2 pp | 46.0 | 43.0 | — | — | +3.4 | 0.00 | source |
| Real Time Big Data | CNN | 02/03/26 | 2,000 | phone | ±2.2 pp | 42.0 | 41.0 | — | — | +1.2 | 0.00 | source |
| Vetor | self-funded | 02/03/26 | 9,000 | not reported | ±1.0 pp | 40.4 | 49.0 | — | — | -9.6 | 0.00 | source |
| Paraná Pesquisas | self-funded | 25/02/26 | 2,080 | in-person | ±2.1 pp | 43.8 | 44.4 | — | — | -0.7 | 0.00 | source |
| AtlasIntel | Bloomberg | 24/02/26 | 4,986 | online | ±1.4 pp | 46.2 | 46.3 | — | — | -0.1 | 0.00 | source |
| Quaest | Genial | 09/02/26 | 2,004 | in-person | ±2.2 pp | 43.0 | 38.0 | — | — | +6.2 | 0.00 | source |
| Futura Inteligencia | Apex | 07/02/26 | 2,000 | not reported | ±2.2 pp | 42.4 | 48.2 | — | — | -6.4 | 0.00 | source |
| Meio/Ideia | Canal Meio | 02/02/26 | 1,500 | phone | ±2.5 pp | 45.8 | 41.1 | — | — | +5.4 | 0.00 | source |
| Gerp | self-funded | 02/02/26 | 2,000 | not reported | ±2.2 pp | 45.0 | 45.0 | — | — | 0.0 | 0.00 | source |
| Paraná Pesquisas | self-funded | 28/01/26 | 2,080 | in-person | ±2.1 pp | 44.8 | 42.2 | — | — | +3.0 | 0.00 | source |
| AtlasIntel | Bloomberg | 20/01/26 | 5,418 | online | ±1.3 pp | 49.0 | 45.0 | — | — | +4.3 | 0.00 | source |
| Futura Inteligencia | Apex | 19/01/26 | 2,000 | not reported | ±2.2 pp | 41.9 | 48.1 | — | — | -6.9 | 0.00 | source |
| Meio/Ideia | Canal Meio | 12/01/26 | 2,000 | phone | ±2.2 pp | 46.2 | 36.0 | — | — | +12.4 | 0.00 | source |
| Quaest | Genial | 11/01/26 | 2,004 | in-person | ±2.2 pp | 45.0 | 38.0 | — | — | +8.4 | 0.00 | source |
| Paraná Pesquisas | self-funded | 22/12/25 | 2,038 | in-person | ±2.2 pp | 44.1 | 41.0 | — | — | +3.6 | 0.00 | source |
| AtlasIntel | Bloomberg | 15/12/25 | 8,154 | online | ±1.1 pp | 53.0 | 41.0 | — | — | +12.8 | 0.00 | source |
| Quaest | Genial | 14/12/25 | 2,004 | in-person | ±2.2 pp | 46.0 | 36.0 | — | — | +12.2 | 0.00 | source |
| Gerp | self-funded | 10/12/25 | 2,000 | not reported | ±2.2 pp | 41.0 | 42.0 | — | — | -1.2 | 0.00 | source |
| Futura Inteligencia | Apex | 09/12/25 | 2,001 | not reported | ±2.2 pp | 42.1 | 41.6 | — | — | +0.6 | 0.00 | source |
| Verita | self-funded | 07/12/25 | 2,519 | not reported | ±2.0 pp | 39.8 | 39.2 | — | — | +0.8 | 0.00 | source |
| Datafolha | Folha/Globo | 04/12/25 | 2,002 | in-person | ±2.2 pp | 51.0 | 36.0 | — | — | +17.2 | 0.00 | source |
| State | Pollster | Fieldwork (end) | n | Method | Lula | Flávio | Registration | Sources |
|---|---|---|---|---|---|---|---|---|
| AC | Verita | 15/09/26 | 1,030 | phone | 32.7 | 61.3 | BR-02386/2026 | source · 2nd source |
| AC | Quaest | 12/10/26 | 1,098 | in-person | 28.0 | 60.0 | BR-05379/2026 | source · 2nd source |
| AL | AtlasIntel | 09/09/26 | 1,208 | online | 50.6 | 40.0 | AL-00447/2026 | source (single source) |
| AL | Verita | 15/09/26 | 1,220 | phone | 52.8 | 40.8 | BR-04383/2026 | source · 2nd source |
| AL | Quaest | 24/09/26 | 804 | in-person | 48.0 | 34.0 | BR-04938/2026 | source · 2nd source |
| AM | AtlasIntel | 03/09/26 | 1,185 | online | 41.3 | 48.0 | AM-04939/2026 | source (single source) |
| AM | Verita | 15/09/26 | 1,220 | phone | 42.2 | 43.1 | BR-07214/2026 | source · 2nd source |
| AM | Quaest | 23/09/26 | 804 | in-person | 45.0 | 40.0 | BR-08101/2026 | source · 2nd source |
| AM | PoderData | 11/10/26 | 1,200 | phone | 49.0 | 49.0 | AM-08245/2026 | source · 2nd source |
| AM | Quaest | 12/10/26 | 1,098 | in-person | 47.0 | 43.0 | AM-09760/2026 | source · 2nd source |
| AP | Verita | 15/09/26 | 1,030 | phone | 43.9 | 46.2 | BR-07861/2026 | source · 2nd source |
| AP | AtlasIntel | 02/10/26 | 800 | online | 50.7 | 43.5 | AP-01238/2026 | source (single source) |
| BA | AtlasIntel | 02/09/26 | 1,804 | online | 58.1 | 29.0 | BA-08891/2026 | source · 2nd source |
| BA | Real Time Big Data | 08/09/26 | 1,600 | phone | 61.0 | 30.0 | BR-06566/2026 | source · 2nd source |
| BA | Verita | 15/09/26 | 2,020 | phone | 55.1 | 40.5 | BR-07197/2026 | source · 2nd source |
| BA | Quaest | 23/09/26 | 900 | in-person | 62.0 | 27.0 | BA-01850/2026 | source · 2nd source |
| BA | Real Time Big Data | 26/09/26 | 1,600 | phone | 64.0 | 28.0 | BR-02319/2026 | source · 2nd source |
| BA | AtlasIntel | 28/09/26 | 2,000 | online | 61.3 | 31.4 | BA-02425/2026 | source · 2nd source |
| CE | AtlasIntel | 02/09/26 | 1,834 | online | 54.6 | 30.1 | BR-07411/2026 | source · 2nd source |
| CE | Verita | 15/09/26 | 1,525 | phone | 54.1 | 36.0 | BR-07551/2026 | source · 2nd source |
| CE | Real Time Big Data | 17/09/26 | 1,600 | phone | 64.0 | 27.0 | BR-03822/2026 | source · 2nd source |
| CE | Quaest | 22/09/26 | 900 | in-person | 59.0 | 27.0 | CE-08268/2026 | source · 2nd source |
| CE | Real Time Big Data | 24/09/26 | 1,600 | phone | 63.0 | 28.0 | BR-05316/2026 | source (single source) |
| DF | Datafolha | 21/08/26 | 910 | in-person | 39.0 | 51.0 | — | source · 2nd source |
| DF | AtlasIntel | 01/09/26 | 1,193 | online | 42.5 | 47.4 | DF-02018/2026 | source (single source) |
| DF | Datafolha | 11/09/26 | 910 | in-person | 39.0 | 51.0 | — | source (single source) |
| DF | Verita | 15/09/26 | 1,220 | phone | 49.7 | 45.4 | BR-01988/2026 | source · 2nd source |
| DF | Quaest | 22/09/26 | 1,104 | in-person | 38.0 | 49.0 | DF-02596/2026 | source · 2nd source |
| DF | Datafolha | 24/09/26 | 910 | in-person | 41.0 | 51.0 | DF-01888/2026 | source · 2nd source |
| DF | Quaest | 28/09/26 | 1,104 | in-person | 33.0 | 48.0 | DF-02515/2026 | source · 2nd source |
| DF | Datafolha | 30/09/26 | 910 | in-person | 39.0 | 51.0 | DF-00905/2026 | source · 2nd source |
| DF | AtlasIntel | 02/10/26 | 1,200 | online | 39.6 | 55.3 | DF-04297/2026 | source (single source) |
| DF | Datafolha | 08/10/26 | 910 | in-person | 43.0 | 49.0 | DF-03365/2026 | source · 2nd source |
| DF | Quaest | 11/10/26 | 1,104 | not reported | 40.0 | 50.0 | DF-08737/2026 | source (single source) |
| DF | Quaest | 20/10/26 | 1,104 | in-person | 39.0 | 50.0 | BR-03163/2026 | source (single source) |
| ES | Verita | 17/09/26 | 1,220 | phone | 43.1 | 50.9 | BR-00729/2026 | source · 2nd source |
| ES | Quaest | 13/10/26 | 1,098 | not reported | 38.0 | 51.0 | BR-01973/2026 | source · 2nd source |
| GO | AtlasIntel | 01/09/26 | 1,214 | online | 32.9 | 54.6 | GO-05293/2026 | source (single source) |
| GO | Verita | 17/09/26 | 1,525 | phone | 33.4 | 53.4 | BR-06701/2026 | source · 2nd source |
| GO | Real Time Big Data | 21/09/26 | 1,600 | phone | 33.0 | 57.0 | — | source · 2nd source |
| MA | Real Time Big Data | 09/09/26 | 1,600 | phone | 65.0 | 30.0 | — | source · 2nd source |
| MA | Verita | 17/09/26 | 1,525 | phone | 53.7 | 38.6 | BR-07504/2026 | source · 2nd source |
| MA | Quaest | 24/09/26 | 900 | in-person | 63.0 | 26.0 | — | source · 2nd source |
| MG | Datafolha | 20/08/26 | 1,204 | in-person | 46.0 | 42.0 | MG-00446/2026 | source · 2nd source |
| MG | AtlasIntel | 04/09/26 | 1,804 | online | 48.7 | 42.1 | — | source (single source) |
| MG | Datafolha | 10/09/26 | 1,204 | in-person | 46.0 | 45.0 | — | source · 2nd source |
| MG | Verita | 17/09/26 | 2,030 | phone | 38.4 | 45.6 | BR-03240/2026 | source · 2nd source |
| MG | Quaest | 22/09/26 | 1,506 | in-person | 40.0 | 40.0 | BR-07664/2026 | source · 2nd source |
| MG | Datafolha | 24/09/26 | 1,204 | in-person | 44.0 | 46.0 | BR-06824/2026 | source · 2nd source |
| MG | Quaest | 28/09/26 | 1,506 | in-person | 41.0 | 40.0 | BR-05155/2026 | source · 2nd source |
| MG | Datafolha | 01/10/26 | 1,204 | in-person | 48.0 | 44.0 | — | source · 2nd source |
| MG | Real Time Big Data | 02/10/26 | 2,000 | phone | 46.0 | 46.0 | BR-01068/2026 | source · 2nd source |
| MG | AtlasIntel | 02/10/26 | 2,189 | online | 47.4 | 50.2 | BR-05974/2026 | source (single source) |
| MS | AtlasIntel | 01/09/26 | 1,204 | online | 36.1 | 57.1 | MS-02047/2026 | source (single source) |
| MS | Real Time Big Data | 09/09/26 | 1,600 | phone | 38.0 | 52.0 | BR-07174/2026 | source (single source) |
| MS | Verita | 17/09/26 | 1,220 | phone | 34.4 | 53.5 | BR-07855/2026 | source · 2nd source |
| MT | AtlasIntel | 09/09/26 | 1,209 | online | 35.2 | 59.1 | MT-02766/2026 | source (single source) |
| MT | Verita | 17/09/26 | 1,220 | phone | 28.1 | 54.9 | BR-02939/2026 | source · 2nd source |
| MT | Quaest | 24/09/26 | 804 | in-person | 30.0 | 56.0 | MT-08098/2026 | source · 2nd source |
| PA | Verita | 17/09/26 | 1,525 | phone | 39.6 | 48.8 | BR-01223/2026 | source · 2nd source |
| PA | Quaest | 25/09/26 | 804 | in-person | 44.0 | 40.0 | — | source · 2nd source |
| PB | AtlasIntel | 02/09/26 | 1,207 | online | 56.3 | 33.4 | PB-01118/2026 | source · 2nd source |
| PB | Verita | 18/09/26 | 1,220 | phone | 51.6 | 40.9 | BR-08734/2026 | source · 2nd source |
| PB | Quaest | 21/09/26 | 804 | in-person | 60.0 | 26.0 | — | source · 2nd source |
| PB | AtlasIntel | 02/10/26 | 1,268 | online | 61.4 | 33.9 | BR-07092/2026 | source (single source) |
| PE | Datafolha | 20/08/26 | 1,204 | in-person | 62.0 | 29.0 | — | source · 2nd source |
| PE | Real Time Big Data | 02/09/26 | 1,600 | phone | 59.0 | 35.0 | BR-05862/2026 | source · 2nd source |
| PE | AtlasIntel | 16/09/26 | 1,793 | online | 57.5 | 36.2 | BR-00357/2026 | source · 2nd source |
| PE | Verita | 18/09/26 | 2,010 | phone | 52.9 | 40.1 | BR-07802/2026 | source · 2nd source |
| PE | AtlasIntel | 20/09/26 | 1,836 | online | 60.5 | 32.7 | PE-02023/2026 | source · 2nd source |
| PE | Quaest | 22/09/26 | 1,302 | in-person | 58.0 | 25.0 | PE-05671/2026 | source · 2nd source |
| PE | Datafolha | 24/09/26 | 1,204 | in-person | 64.0 | 29.0 | PE-08125/2026 | source · 2nd source |
| PE | Real Time Big Data | 26/09/26 | 1,600 | phone | 61.0 | 35.0 | BR-02938/2026 | source · 2nd source |
| PE | Quaest | 28/09/26 | 1,302 | in-person | 61.0 | 24.0 | PE-00324/2026 | source · 2nd source |
| PE | Datafolha | 01/10/26 | 1,204 | in-person | 65.0 | 29.0 | PE-06822/2026 | source · 2nd source |
| PI | Verita | 18/09/26 | 1,220 | phone | 57.6 | 28.7 | BR-07059/2026 | source · 2nd source |
| PI | AtlasIntel | 28/09/26 | 1,218 | online | 70.0 | 26.0 | PI-07497/2026 | source · 2nd source |
| PR | AtlasIntel | 09/09/26 | 1,799 | online | 32.1 | 60.7 | PR-09655/2026 | source (single source) |
| PR | Real Time Big Data | 15/09/26 | 1,600 | phone | 30.0 | 61.0 | BR-08843/2026 | source · 2nd source |
| PR | AtlasIntel | 16/09/26 | 1,794 | online | 38.8 | 57.5 | PR-02671/2026 | source · 2nd source |
| PR | Verita | 18/09/26 | 2,010 | phone | 30.6 | 58.9 | BR-01839/2026 | source · 2nd source |
| PR | Real Time Big Data | 28/09/26 | 1,600 | phone | 30.0 | 61.0 | BR-03875/2026 | source · 2nd source |
| RJ | Real Time Big Data | 01/09/26 | 2,000 | phone | 41.0 | 45.0 | — | source (single source) |
| RJ | Datafolha | 10/09/26 | 1,204 | in-person | 41.0 | 49.0 | — | source · 2nd source |
| RJ | Real Time Big Data | 12/09/26 | 2,000 | phone | 41.0 | 47.0 | BR-09111/2026 | source · 2nd source |
| RJ | Verita | 18/09/26 | 2,030 | phone | 39.3 | 53.4 | BR-02698/2026 | source · 2nd source |
| RJ | AtlasIntel | 20/09/26 | 1,829 | online | 44.7 | 44.9 | RJ-01150/2026 | source (single source) |
| RJ | Quaest | 22/09/26 | 1,302 | in-person | 36.0 | 44.0 | RJ-04982/2026 | source · 2nd source |
| RJ | Datafolha | 24/09/26 | 1,204 | in-person | 42.0 | 51.0 | RJ-02205/2026 | source · 2nd source |
| RJ | Quaest | 28/09/26 | 1,302 | in-person | 34.0 | 44.0 | RJ-04419/2026 | source · 2nd source |
| RJ | Real Time Big Data | 29/09/26 | 2,000 | phone | 41.0 | 52.0 | BR-05193/2026 | source · 2nd source |
| RJ | Datafolha | 01/10/26 | 1,204 | in-person | 42.0 | 51.0 | RJ-02070/2026 | source · 2nd source |
| RN | Verita | 18/09/26 | 1,220 | phone | 55.4 | 37.4 | BR-02399/2026 | source · 2nd source |
| RN | Quaest | 24/09/26 | 804 | in-person | 56.0 | 30.0 | RN-01492/2026 | source · 2nd source |
| RN | Quaest | 12/10/26 | 1,098 | in-person | 59.0 | 33.0 | RN-03210/2026 | source · 2nd source |
| RO | Verita | 19/09/26 | 1,220 | phone | 30.5 | 63.9 | BR-07923/2026 | source · 2nd source |
| RR | AtlasIntel | 09/09/26 | 1,200 | online | 25.6 | 66.4 | RR-07280/2026 | source · 2nd source |
| RR | Verita | 19/09/26 | 1,030 | phone | 23.5 | 70.8 | BR-07242/2026 | source · 2nd source |
| RR | Quaest | 24/09/26 | 804 | in-person | 22.0 | 67.0 | — | source · 2nd source |
| RR | AtlasIntel | 02/10/26 | 800 | online | 29.3 | 65.3 | RR-07765/2026 | source (single source) |
| RS | AtlasIntel | 01/09/26 | 1,783 | online | 42.0 | 50.4 | RS-00652/2026 | source · 2nd source |
| RS | Real Time Big Data | 09/09/26 | 1,600 | phone | 42.0 | 53.0 | BR-00631/2026 | source · 2nd source |
| RS | Verita | 19/09/26 | 2,020 | phone | 37.9 | 55.7 | BR-06983/2026 | source · 2nd source |
| RS | AtlasIntel | 20/09/26 | 1,819 | online | 44.4 | 47.9 | BR-01816/2026 | source · 2nd source |
| RS | Real Time Big Data | 28/09/26 | 1,600 | phone | 43.0 | 53.0 | BR-08546/2026 | source (single source) |
| SC | AtlasIntel | 09/09/26 | 1,211 | online | 27.5 | 61.5 | SC-02036/2026 | source · 2nd source |
| SC | Real Time Big Data | 16/09/26 | 1,600 | phone | 30.0 | 62.0 | BR-09582/2026 | source · 2nd source |
| SE | Verita | 19/09/26 | 1,220 | phone | 56.7 | 37.6 | BR-01212/2026 | source · 2nd source |
| SE | Quaest | 23/09/26 | 804 | in-person | 56.0 | 30.0 | — | source · 2nd source |
| SP | Datafolha | 19/08/26 | 1,610 | in-person | 42.0 | 47.0 | SP-01806/2026 | source · 2nd source |
| SP | Datafolha | 10/09/26 | 1,610 | in-person | 42.0 | 47.0 | — | source · 2nd source |
| SP | Real Time Big Data | 12/09/26 | 2,000 | phone | 44.0 | 50.0 | BR-05824/2026 | source (single source) |
| SP | Real Time Big Data | 19/09/26 | 2,000 | phone | 45.0 | 51.0 | BR-09702/2026 | source · 2nd source |
| SP | Verita | 19/09/26 | 3,025 | phone | 40.6 | 52.3 | BR-04470/2026 | source · 2nd source |
| SP | AtlasIntel | 20/09/26 | 1,800 | online | 42.8 | 48.8 | SP-02676/2026 | source · 2nd source |
| SP | Quaest | 22/09/26 | 1,800 | in-person | 36.0 | 42.0 | BR-08886/2026 | source · 2nd source |
| SP | Datafolha | 23/09/26 | 1,610 | in-person | 41.0 | 49.0 | BR-09355/2026 | source · 2nd source |
| SP | Real Time Big Data | 28/09/26 | 2,000 | phone | 43.0 | 51.0 | — | source · 2nd source |
| SP | Datafolha | 30/09/26 | 1,610 | in-person | 45.0 | 48.0 | BR-02676/2026 | source · 2nd source |
| TO | Verita | 19/09/26 | 1,220 | phone | 33.6 | 61.1 | BR-09946/2026 | source · 2nd source |
| TO | Quaest | 13/10/26 | 1,098 | not reported | 42.0 | 48.0 | TO-03829/2026 | source · 2nd source |
| TO | Paraná Pesquisas | 17/10/26 | 1,500 | phone | 41.9 | 52.9 | BR-09051/2026 | source (single source) |
| Pollster | Sponsor | Fieldwork through | n | Method | Lula | Flávio | Cury | Renan | Caiado | Zema | Others | Blank/spoiled | Undec. | TSE registration | Sources |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Quaest | Globo/O Globo | 03/10 | 3,702 | in-person | 40 | 38 | 3 | 3 | 3 | 0 | 0 | 10 | 3 | BR-02197/2026 | source · 2nd |
| Datafolha | Folha de S.Paulo/Globo | 03/10 | 4,006 | in-person | 42 | 40 | 3 | 3 | 4 | 0 | 0 | 4 | 3 | BR-01708/2026 | source · 2nd |
| Palver | self-funded | 03/10 | 5,000 | online | 43 | 47 | 1 | 7 | 1 | 0 | 0 | 0 | 0 | BR-00198/2026 | source · 2nd |
| PoderData | Aya | 02/10 | 4,000 | phone | 42 | 41 | 3 | 3 | 2 | 1 | 2 | 4 | 2 | BR-03519/2026 | source · 2nd |
| CNT/MDA | CNT | 02/10 | 2,007 | in-person | 43.1 | 38 | 3.2 | 1.5 | 3 | 0.8 | 0.5 | 5 | 4.7 | BR-04756/2026 | source · 2nd |
| Vox Brasil | self-funded | 01/10 | 2,100 | not reported | 40.4 | 41.2 | 1.2 | 2.2 | 3.3 | 1.3 | 0.2 | 3.2 | 7 | BR-00148/2026 | source · 2nd |
| Datafolha | Folha de S.Paulo/Globo | 01/10 | 2,506 | in-person | 42 | 38 | 4 | 3 | 3 | 1 | 2 | 5 | 2 | BR-08039/2026 | source · 2nd |
| Real Time Big Data | Real Time Big Data (self-funded) | 30/09 | 2,000 | phone | 43 | 39 | 3 | 4 | 3 | 1 | 1 | 3 | 3 | BR-09503/2026 | source · 2nd |
| DataTrends | self-funded | 30/09 | 2,000 | phone | 41 | 39 | 4 | 3 | 3 | — | 1 | 4 | 4 | BR-04414/2026 | source · 2nd |
| Indexa | Broadcast/Estadão | 29/09 | 2,000 | phone | 39 | 34 | 4 | 3 | 4 | 1 | 0 | 4 | 7 | BR-00698/2026 | source · 2nd |
| Futura Inteligencia | Apex/100% Cidades | 29/09 | 2,000 | phone | 39.4 | 42.2 | 3.8 | 3.2 | 4 | 1.6 | 1.2 | 2.4 | 2.6 | BR-01122/2026 | source · 2nd |
| Vox Brasil | self-funded | 28/09 | 2,100 | in-person | 41.1 | 37.8 | 2.2 | 2.7 | 3.8 | 1.8 | 0.3 | 3.5 | 6.8 | BR-00895/2026 | source · 2nd |
| Meio/Ideia | Canal Meio | 28/09 | 2,000 | phone | 39.4 | 38.4 | 6.7 | 4.5 | 4.4 | 3.5 | 1.4 | 0.7 | 1.5 | BR-08706/2026 | source · 2nd |
| Gerp | self-funded | 28/09 | 2,400 | phone | 40 | 42 | 5 | 3 | 3 | 1 | 1 | 2 | 3 | BR-03929/2026 | source · 2nd |
| AtlasIntel | Bloomberg (AtlasIntel self-funded) | 28/09 | 5,005 | online | 45.3 | 42.2 | 2 | 5.2 | 1.8 | 0.9 | 0.5 | 0.9 | 1.2 | BR-04391/2026 | source · 2nd |
| Palver | self-funded | 27/09 | 5,000 | online | 44 | 44 | 1 | 8 | 1 | 1 | 0 | 1 | 1 | BR-02990/2026 | source · 2nd |
| BTG/Nexus | BTG Pactual | 27/09 | 2,000 | phone | 42 | 37 | 5 | 4 | 5 | 1 | 1 | 3 | 2 | BR-07557/2026 | source · 2nd |
| Quaest | Globo/O Globo | 27/09 | 2,004 | in-person | 39 | 34 | 4 | 3 | 4 | 1 | 0 | 10 | 5 | BR-06520/2026 | source · 2nd |
| Real Time Big Data | Real Time Big Data (self-funded) | 23/09 | 2,000 | phone | 41 | 37 | 6 | 6 | 2 | 1 | 1 | 3 | 3 | BR-04202/2026 | source · 2nd |
| PoderData | PoderData (self-funded)/Aya | 23/09 | 3,000 | phone | 41 | 39 | 6 | 3 | 2 | 1 | 3 | 4 | 2 | BR-01739/2026 | source · 2nd |
| Palver | self-funded | 23/09 | 5,000 | online | 43 | 43 | 2 | 8 | 1 | 1 | 1 | 1 | 1 | BR-09587/2026 | source · 2nd |
| Futura Inteligencia | Apex/100% Cidades | 23/09 | 2,000 | phone | 38.4 | 40.4 | 5.6 | 2.4 | 5.7 | 0.9 | 1.2 | 2.1 | 3.2 | BR-05268/2026 | source · 2nd |
| Datafolha | Folha de S.Paulo/Globo | 23/09 | 2,002 | in-person | 40 | 36 | 5 | 3 | 4 | 1 | 1 | 5 | 2 | BR-00304/2026 | source · 2nd |
| Alfa Inteligencia | TMC | 23/09 | 2,700 | in-person | 40 | 33 | 6 | 3 | 5 | 1 | 1 | 6 | 5 | BR-02512/2026 | source · 2nd |
| AtlasIntel | Bloomberg (AtlasIntel self-funded) | 22/09 | 5,015 | online | 45.8 | 43.4 | 2.1 | 4.5 | 1.3 | 0.9 | 0.8 | 0.7 | 0.5 | BR-04739/2026 | source · 2nd |
| American Analytics | Times Brasil/CNBC | 20/09 | 2,000 | not reported | 38 | 34 | 5 | 4 | 4 | 1 | 0 | 6 | 8 | BR-032587/26 | source · 2nd |
| BTG/Nexus | BTG Pactual | 20/09 | 2,006 | phone | 40 | 37 | 6 | 3 | 5 | 1 | 1 | 4 | 2 | BR-00485/2026 | source · 2nd |
| Quaest | Globo/O Globo | 20/09 | 2,004 | in-person | 37 | 33 | 6 | 3 | 4 | 1 | 1 | 7 | 8 | BR-06004/2026 | source · 2nd |
| Verita | self-funded | 19/09 | 40,500 | phone | 40 | 43.6 | 5.4 | 2.9 | 1.8 | 0.6 | 1.3 | 0.7 | 3.6 | — | source · 2nd |
| Palver | self-funded | 18/09 | 5,000 | online | 41 | 42 | 2 | 11 | 1 | 1 | — | — | 1 | BR-00860/2026 | source · 2nd |
| PoderData | PoderData (self-funded)/Aya | 16/09 | 3,000 | phone | 37 | 36 | 8 | 3 | 3 | 1 | 6 | 4 | 2 | BR-00360/2026 | source · 2nd |
| Gerp | self-funded | 16/09 | 2,400 | phone | 37 | 40 | 7 | 2 | 3 | 1 | 1 | 4 | 4 | BR-00535/2026 | source · 2nd |
| Datafolha | Folha de S.Paulo/Globo | 16/09 | 2,002 | in-person | 39 | 36 | 6 | 3 | 4 | 2 | 2 | 6 | 3 | BR-04029/2026 | source · 2nd |
| AtlasIntel | Bloomberg (AtlasIntel self-funded) | 16/09 | 5,018 | online | 44.1 | 41.7 | 3.5 | 5.1 | 1.7 | 1.1 | 0.7 | 1.6 | 0.5 | BR-06221/2026 | source · 2nd |
| Futura Inteligencia | Apex/100% Cidades | 15/09 | 2,000 | phone | 38.3 | 37.7 | 6.5 | 3.4 | 4.5 | 1 | 2.4 | 2.7 | 3.4 | BR-00749/2026 | source · 2nd |
| DataTrends | self-funded | 14/09 | 2,000 | phone | 39 | 35 | 6 | 4 | 3 | 1 | 0 | 4 | 8 | BR-08691/2026 | source · 2nd |
| Quaest | Globo/O Globo | 13/09 | 2,004 | in-person | 36 | 31 | 7 | 4 | 4 | 1 | 0 | 7 | 10 | BR-03607/2026 | source · 2nd |
| CNT/MDA | CNT | 13/09 | 2,002 | in-person | 40.5 | 30.4 | 6 | 2.7 | 3 | 1 | 2.9 | 6 | 7.5 | BR-06902/2026 | source · 2nd |
| BTG/Nexus | BTG Pactual | 13/09 | 2,003 | phone | 42 | 37 | 6 | 2 | 5 | 1 | 1 | 4 | 2 | BR-04076/2026 | source · 2nd |
| Indexa | Broadcast/Estadão | 13/09 | 2,000 | phone | 38 | 34 | 6 | 3 | 4 | 1 | 0 | 10 | 4 | BR-03482/2026 | source · 2nd |
| Futura Inteligencia | Apex/100% Cidades | 10/09 | 2,000 | phone | 39.2 | 33.6 | 6.4 | 4.3 | 4.4 | 0.7 | 3 | 3.1 | 5.2 | BR-02322/2026 | source · 2nd |
| Datafolha | Folha de S.Paulo/Globo | 10/09 | 2,002 | in-person | 39 | 35 | 6 | 3 | 4 | 2 | 4 | 6 | 4 | BR-01833/2026 | source · 2nd |
| PoderData | PoderData (self-funded)/Aya | 09/09 | 3,000 | phone | 38 | 36 | 10 | 3 | 2 | 1 | 5 | 3 | 1 | BR-04914/2026 | source · 2nd |
| AtlasIntel | Bloomberg (AtlasIntel self-funded) | 09/09 | 5,000 | online | 43 | 37.4 | 6.6 | 6.5 | 1.1 | 2.1 | 2.3 | 0.6 | 0.4 | BR-01452/2026 | source · 2nd |
| Gerp | self-funded | 08/09 | 2,400 | phone | 34 | 37 | 6 | 4 | 5 | 1 | 2 | 4 | 8 | BR-00251/2026 | source · 2nd |
| Meio/Ideia | Canal Meio | 07/09 | 1,500 | phone | 38.4 | 37.3 | 6.6 | 4 | 4 | 1.5 | 3.1 | 2.5 | 2.8 | BR-07935/2026 | source · 2nd |
| Palver | self-funded | 07/09 | 5,000 | online | 40 | 40 | 4 | 11 | 1 | 1 | 2 | 0 | 1 | BR-05420/2026 | source · 2nd |
| BTG/Nexus | BTG Pactual | 07/09 | 2,002 | phone | 39 | 35 | 9 | 4 | 4 | 1 | 2 | 3 | 2 | BR-06790/2026 | source · 2nd |
| Quaest | Globo/O Globo | 06/09 | 2,004 | in-person | 36 | 29 | 8 | 3 | 3 | 2 | 1 | 8 | 10 | BR-01720/2026 | source · 2nd |
| Verita | self-funded | 04/09 | 3,804 | not reported | 38.4 | 38.9 | 12.2 | 3.7 | 1.9 | 0.3 | 2.6 | 1.1 | 0.9 | BR-09426/2026 | source · 2nd |
| PoderData | PoderData (self-funded)/Aya | 02/09 | 3,000 | phone | 37 | 34 | 10 | 3 | 2 | 1 | 8 | 5 | 2 | BR-07561/2026 | source · 2nd |
| Datafolha | Folha de S.Paulo/Globo | 02/09 | 2,002 | in-person | 38 | 33 | 8 | 3 | 4 | 2 | 3 | 6 | 3 | BR-03669/2026 | source · 2nd |
| Futura Inteligencia | Apex/100% Cidades | 01/09 | 2,000 | phone | 38.7 | 33.6 | 9.9 | 2.8 | 3.5 | 1.6 | 3.8 | 2 | 4.1 | BR-02793/2026 | source · 2nd |
| Quaest | Globo/O Globo | 01/09 | 2,004 | in-person | 37 | 29 | 10 | 3 | 1 | 1 | 1 | 7 | 11 | BR-07065/2026 | source · 2nd |
| Real Time Big Data | Real Time Big Data (self-funded) | 31/08 | 2,000 | phone | 38 | 30 | 11 | 7 | 4 | 2 | 2 | 3 | 3 | BR-03490/2026 | source · 2nd |
| AtlasIntel | Bloomberg (AtlasIntel self-funded) | 30/08 | 5,014 | online | 43.4 | 33.7 | 7.8 | 7.6 | 3.3 | 1 | 3 | 0.1 | 0.2 | BR-07972/2026 | source · 2nd |
| BTG/Nexus | BTG Pactual | 30/08 | 2,005 | phone | 39 | 33 | 11 | 3 | 5 | 1 | 1 | 3 | 3 | BR-08900/2026 | source · 2nd |
| Vox Brasil | self-funded | 27/08 | 2,100 | in-person | 37.1 | 34.8 | 2.6 | 3.3 | 5 | 2.8 | 4.1 | 3.5 | 6.8 | BR-05519/2026 | source · 2nd |
| PoderData | PoderData (self-funded)/Aya | 26/08 | 2,400 | phone | 38 | 35 | 4 | 4 | 4 | 2 | 7 | 5 | 2 | BR-04974/2026 | source · 2nd |
| Gerp | AESP | 25/08 | 2,400 | phone | 37 | 38 | 1 | 3 | 3 | 1 | 4 | 4 | 8 | BR-03547/2026 | source · 2nd |
| Indexa | Broadcast/Estadão | 23/08 | 2,000 | phone | 39 | 34 | 1 | 4 | 5 | 1 | 0 | 10 | 6 | BR-06366/2026 | source · 2nd |
| BTG/Nexus | BTG Pactual | 23/08 | 2,006 | phone | 41 | 37 | 2 | 3 | 5 | 3 | 1 | 5 | 3 | BR-09028/2026 | source · 2nd |
| Verita | self-funded | 20/08 | 3,840 | phone | 39.3 | 39.1 | 2.2 | 3.8 | 3.3 | 1.3 | 7 | 1.8 | 2.4 | BR-04006/2026 | source · 2nd |
| Datafolha | Folha de S.Paulo/Globo | 20/08 | 2,058 | in-person | 39 | 33 | 2 | 4 | 5 | 3 | 4 | 6 | 4 | BR-04496/2026 | source · 2nd |
| BTG/Nexus | BTG Pactual | 16/08 | 2,003 | phone | 41 | 36 | 1 | 4 | 4 | 4 | 1 | 5 | 2 | BR-03317/2026 | source · 2nd |
| Quaest | Globo/O Globo | 13/08 | 2,004 | in-person | 38 | 31 | 2 | 4 | 4 | 2 | 1 | 8 | 10 | BR-06773/2026 | source · 2nd |
| PoderData | PoderData (self-funded)/Aya | 12/08 | 2,400 | phone | 41 | 35 | 3 | 4 | 4 | 3 | 3 | 4 | 2 | BR-06868/2026 | source · 2nd |
| Gerp | self-funded | 10/08 | 2,400 | phone | 38 | 38 | 1 | 5 | 4 | 2 | 1 | 4 | 7 | BR-08045/2026 | source · 2nd |
| CNT/MDA | CNT | 09/08 | 2,002 | in-person | 42.4 | 28.7 | 1.2 | 2.8 | 4 | 3.3 | 3.4 | 6.8 | 7.2 | BR-06935/2026 | source · 2nd |
| BTG/Nexus | BTG Pactual | 09/08 | 2,001 | phone | 40 | 35 | 1 | 4 | 5 | 3 | 3 | 5 | 3 | BR-08428/2026 | source · 2nd |
| Palver | self-funded | 07/08 | 5,000 | online | 44 | 40 | 0 | 10 | 2 | 1 | 1 | 1 | 0 | BR-06596/2026 | source · 2nd |
| Futura Inteligencia | Apex/100% Cidades | 07/08 | 2,000 | phone | 38.8 | 34.1 | 1.2 | 3.6 | 5.9 | 3.6 | 2 | 6.2 | 4.8 | BR-08109/2026 | source · 2nd |
| Quaest | Genial (Banco Genial S.A.) | 03/08 | 2,004 | in-person | 39 | 30 | 1 | 4 | 4 | 2 | 2 | 8 | 10 | BR-06591/2026 | source · 2nd |
| Meio/Ideia | Canal Meio | 03/08 | 1,500 | phone | 43 | 35 | 1.7 | 4.7 | 5.7 | 2.6 | 1.3 | 2 | 4 | BR-04579/2026 | source · 2nd |
| BTG/Nexus | BTG Pactual | 02/08 | 2,002 | phone | 41 | 37 | 1 | 4 | 5 | 3 | 2 | 4 | 3 | BR-02874/2026 | source · 2nd |
After the first round, declared runoff vote by first-round vote (PoderData/Aya Oct 10–12, via TMC), % Flávio / Lula: Cury 45/43 · Caiado 44/30 · Renan Santos 38/33 · other candidate 57/17 (Zema has no row of its own); blank/spoiled: Renan 27%, Caiado 14%, Cury 8%. Datafolha Oct 6–7, only the aggregate of the four: Flávio 46, Lula 32, blank/spoiled 15. Before the first round, declared second choice (Quaest Sep 3–6), % of each candidate's voters who migrate to Flávio / Lula in the runoff: Zema 59/4 · Renan Santos 58/14 · Marçal 47/19 · Cury 46/24 · Caiado 36/25. The model uses the post-first-round matrix (PoderData) and checks the aggregate against Datafolha.
modelo_eleicao_2026.py (recomputes house effects, simulations and SVGs; after Oct 4, fill in resultado_1turno.csv to switch on the post-first-round regime) and then montar_painel.py / montar_standalone.py. The files below are embedded in this HTML.# -*- coding: utf-8 -*-
"""
Espaço de estados para agregação de pesquisas (núcleo do modelo v3).
y_i = x_{t(i)} + h_{p(i)} + ε_i ε_i ~ N(0, se_i² + σ_{p(i)}²)
x_t = x_{t-1} + η_t η_t ~ N(0, σ_rw²)
h_p ~ N(0, τ²) com Σ_{p ∈ âncora} q_p·h_p = 0 (identificação pelos institutos calibrados)
Estimação por ECM: (1) dado h e σ_p, σ_rw por MAP (prior log-normal) e suavizador RTS para x_t;
(2) dado x_t, atualiza h_p (média posterior ridge) e σ_p² (empírico-Bayes, encolhido para a mediana).
Referências: Jackman (2005), Linzer (2013), Heidemanns–Gelman–Morris (2020).
"""
import math
import numpy as np
from scipy.optimize import minimize_scalar
def _kalman(y, day, R, sig_rw, n_days, x0, keep=False, q_extra=None):
"""Filtro para uma série diária. y, day, R ordenados por dia. Retorna loglik e, se keep, trajetórias.
q_extra: dict dia → variância adicional do passeio nesse dia (quebra estrutural, ex.: o dia seguinte ao 1º turno)."""
x, P = x0, 100.0
ll = 0.0
xf = np.empty(n_days); Pf = np.empty(n_days); xp = np.empty(n_days); Pp = np.empty(n_days)
obs_by_day = {}
for i, d in enumerate(day):
obs_by_day.setdefault(int(d), []).append(i)
q = sig_rw ** 2
q_extra = q_extra or {}
for d in range(n_days):
if d > 0:
P += q + q_extra.get(d, 0.0)
xp[d], Pp[d] = x, P
for i in obs_by_day.get(d, []):
F = P + R[i]
v = y[i] - x
ll += -0.5 * (math.log(2 * math.pi * F) + v * v / F)
K = P / F
x += K * v
P *= (1 - K)
xf[d], Pf[d] = x, P
if not keep:
return ll
# suavizador RTS
xs = xf.copy(); Ps = Pf.copy()
for d in range(n_days - 2, -1, -1):
C = Pf[d] / Pp[d + 1] if Pp[d + 1] > 0 else 0.0
xs[d] = xf[d] + C * (xs[d + 1] - xp[d + 1])
Ps[d] = Pf[d] + C * C * (Ps[d + 1] - Pp[d + 1])
return ll, xs, Ps, xf, Pf
def ajustar(y, day, se2, inst, ancora, q_ancora=None, tau_h=1.5, prior_rw=(math.log(0.25), 0.6),
k_sigma=4.0, sigma_floor=0.8, n_iter=15, tol=0.01, sig_rw_fixo=None, prior_h=None, tau_prior_h=None,
n_days=None, q_extra=None, a_h=None):
"""
y, day, se2: arrays (observação, dia inteiro desde a origem, variância amostral).
inst: array de rótulos. ancora: conjunto de institutos que identificam o nível.
q_ancora: pesos dos institutos da âncora (default 1). prior_h: dict instituto → média a priori do efeito de casa
(usado no regime pós-1º turno); tau_prior_h: desvio desse prior (default tau_h). q_extra: ver _kalman.
a_h: fator por observação com que o efeito de casa entra (y_i = x + a_i·h_p + ε); default 1. Serve à persistência parcial do efeito
de casa depois do 1º turno (a = λ < 1 nas pesquisas posteriores a 4/out): a observação identifica a_i·h, não h.
Retorna dict com x (nível suavizado por dia), P (variância), house, house_var (variância posterior de cada efeito de casa),
var_ancora (variância da média da âncora, que desloca o nível), sigma_inst, sig_rw, sig_rw_ml, loglik, resid.
"""
y = np.asarray(y, float); day = np.asarray(day, int); se2 = np.asarray(se2, float); inst = np.asarray(inst)
a_h = np.ones(len(y)) if a_h is None else np.asarray(a_h, float)
order = np.argsort(day, kind="stable")
y, day, se2, inst, a_h = y[order], day[order], se2[order], inst[order], a_h[order]
n_days = max(int(day.max()) + 1, n_days or 0) # o estado é propagado até n_days−1 (ex.: hoje) sem observações
insts = list(dict.fromkeys(inst))
idx = {p: np.where(inst == p)[0] for p in insts}
q_ancora = q_ancora or {}
prior_h = prior_h or {}
tau_prior_h = tau_prior_h or tau_h
h = {p: float(prior_h.get(p, 0.0)) for p in insts}
sig_p = {p: 2.0 for p in insts}
sig_rw = 0.25 if sig_rw_fixo is None else sig_rw_fixo
x0 = float(y[0])
hist_ll = []
for it in range(n_iter):
h_arr = a_h * np.array([h[p] for p in inst])
R = se2 + np.array([sig_p[p] ** 2 for p in inst])
y_adj = y - h_arr
if sig_rw_fixo is None:
def neg_post(z):
ll = _kalman(y_adj, day, R, math.exp(z), n_days, x0, q_extra=q_extra)
lp = -0.5 * ((z - prior_rw[0]) / prior_rw[1]) ** 2
return -(ll + lp)
opt = minimize_scalar(neg_post, bounds=(math.log(0.02), math.log(2.0)), method="bounded",
options={"xatol": 1e-3})
sig_rw = math.exp(opt.x)
ll, xs, Ps, xf, Pf = _kalman(y_adj, day, R, sig_rw, n_days, x0, keep=True, q_extra=q_extra)
hist_ll.append(ll)
# (2) efeitos de casa: média posterior sob h_p ~ N(prior, τ²) dado x suavizado
x_at = xs[day]
h_new = {}
for p in insts:
ii = idx[p]
w = 1.0 / R[ii]
tau2 = (tau_prior_h if p in prior_h else tau_h) ** 2
m0 = prior_h.get(p, 0.0)
h_new[p] = float((np.sum(w * a_h[ii] * (y[ii] - x_at[ii])) + m0 / tau2) / (np.sum(w * a_h[ii] ** 2) + 1.0 / tau2))
# identificação: média ponderada da âncora = 0 (o deslocamento vai para o nível)
anc = [p for p in insts if p in ancora]
if anc:
wq = np.array([q_ancora.get(p, 1.0) for p in anc])
c = float(np.sum(wq * np.array([h_new[p] for p in anc])) / wq.sum())
else:
c = float(np.mean(list(h_new.values())))
h_new = {p: v - c for p, v in h_new.items()}
xs = xs + c; xf = xf + c; x0 = x0 + c
delta = max(abs(h_new[p] - h[p]) for p in insts)
h = h_new
# (3) variância extra por instituto (EB): resíduo² − variância amostral − variância do estado, encolhido para a mediana
resid = y - xs[day] - a_h * np.array([h[p] for p in inst])
excess = {}
for p in insts:
ii = idx[p]
e2 = float(np.mean(resid[ii] ** 2 + Ps[day[ii]] - se2[ii]))
excess[p] = (len(ii), max(e2, 0.0))
med = float(np.median([v for _, v in excess.values()]))
sig_p = {p: math.sqrt(max((n * e2 + k_sigma * med) / (n + k_sigma), sigma_floor ** 2)) for p, (n, e2) in excess.items()}
if delta < tol and it >= 3:
break
# verossimilhança sem prior, para transparência sobre σ_rw
h_arr = a_h * np.array([h[p] for p in inst]); R = se2 + np.array([sig_p[p] ** 2 for p in inst]); y_adj = y - h_arr
ml = minimize_scalar(lambda z: -_kalman(y_adj, day, R, math.exp(z), n_days, x0, q_extra=q_extra), bounds=(math.log(0.02), math.log(2.0)),
method="bounded", options={"xatol": 1e-3})
ll, xs, Ps, xf, Pf = _kalman(y_adj, day, R, sig_rw, n_days, x0, keep=True, q_extra=q_extra)
resid = y - xs[day] - h_arr
# incerteza dos efeitos de casa (tratados como conhecidos no filtro): variância posterior de cada h_p e da média da âncora
house_var = {p: float(1.0 / (np.sum(a_h[idx[p]] ** 2 / R[idx[p]]) + 1.0 / ((tau_prior_h if p in prior_h else tau_h) ** 2))) for p in insts}
anc = [p for p in insts if p in ancora]
if anc:
wq = np.array([q_ancora.get(p, 1.0) for p in anc])
var_ancora = float(np.sum(wq ** 2 * np.array([house_var[p] for p in anc])) / wq.sum() ** 2)
else:
var_ancora = float(np.mean(list(house_var.values())) / len(insts))
return {"x": xs, "P": Ps, "x_filtro": xf, "P_filtro": Pf, "house": h, "house_var": house_var, "var_ancora": var_ancora, "sigma_inst": sig_p, "sig_rw": sig_rw,
"sig_rw_ml": math.exp(ml.x), "loglik": ll, "resid": resid, "order": order, "R": R, "iter": it + 1,
"n_days": n_days, "insts": insts}
def validacao_rolling(y, day, se2, inst, ancora, q_ancora, dias, horizonte=7, a_h=None, **kw):
"""Origem móvel: para cada dia d em `dias`, ajusta com observações até d e prevê as de (d, d+horizonte].
`a_h` é a persistência do efeito de casa por observação (λ nas pesquisas pós-1º turno), a mesma do ajuste validado.
Retorna lista de (dia, y_obs, y_prev, sd_prev, instituto, instituto_ja_visto)."""
y = np.asarray(y, float); day = np.asarray(day, int); se2 = np.asarray(se2, float); inst = np.asarray(inst)
a_h = np.ones(len(y)) if a_h is None else np.asarray(a_h, float)
out = []
for d in dias:
m = day <= d
if m.sum() < 20:
continue
fit = ajustar(y[m], day[m], se2[m], inst[m], ancora, q_ancora, n_iter=8, n_days=d + 1, a_h=a_h[m], **kw) # estado propagado até o dia d
x_d, P_d = fit["x"][d], fit["P"][d]
f = (day > d) & (day <= d + horizonte)
for i in np.where(f)[0]:
hp = fit["house"].get(inst[i], 0.0)
sp = fit["sigma_inst"].get(inst[i], float(np.median(list(fit["sigma_inst"].values()))))
k = day[i] - d
sd = math.sqrt(P_d + k * fit["sig_rw"] ** 2 + se2[i] + sp ** 2)
out.append((int(d), float(y[i]), float(x_d + a_h[i] * hp), sd, str(inst[i]), bool(inst[i] in fit["house"])))
return out
# -*- coding: utf-8 -*-
"""
Modelo do 2º turno estimado por MCMC (Markov chain Monte Carlo): mesmo modelo de espaco_estados.py, com o posterior completo.
y_i = x_{t(i)} + μ_{r(i)} + β + h_{p(i)} + ε_i ε_i ~ N(0, se_i² + σ_{p(i)}²)
x_t = x_{t-1} + η_t η_t ~ N(0, σ_rw²) até o 1º turno · + SD_QUEBRA² no dia seguinte · N(0, σ_entre²) depois
h_p ~ N(prior_p, τ²) com Σ_{p ∈ âncora} q_p·h_p = 0
β ~ t₅(0, sd_vies) erro sistemático comum das pesquisas (μ_r = desconto histórico do regime: antes/depois do 1º turno)
obs. extras: nível estrutural (voto contado + transferência) = x_{T1+1} + e, sem viés de pesquisa
log σ_rw ~ N(log 0,25; 0,6) · log σ_entre ~ N(log σ_entre_hist; 0,5)
log σ_p ~ N(m, 0,5²) truncada em σ_p ≥ 0,8 · m ~ N(log 2; 0,5²)
previsão: x_{T2} = x_{último dia} + deriva, deriva ~ t₅ com desvio sqrt(Σ σ²_dia) (como em simular())
O modelo atual (espaco_estados.ajustar + simular) fixa σ_rw, σ_p, os efeitos de casa e o nível em estimativas pontuais e sorteia
a deriva e o viés por fora. Aqui tudo é sorteado em conjunto. Amostrador (Gibbs com passos de Metropolis):
1. σ_rw e σ_entre por Metropolis com (x, β, h) integrados: dado o resto, o modelo é linear-gaussiano e a verossimilhança marginal
sai da fatoração de Cholesky da precisão conjunta — mistura muito melhor que condicionar na trajetória;
2. (x, β, h) num bloco só, sorteio exato da normal conjunta (a restrição da âncora entra como observação de variância ~0);
3. σ_p por Metropolis, dado o resíduo de cada instituto; m por Metropolis; 4. escala da t do viés (ω) por Gama conjugada.
Diagnósticos: R̂ dividido e ESS multicadeia (Vehtari et al. 2021, sem normalização por postos).
"""
import math
import numpy as np
from scipy.linalg import cholesky, solve_triangular
from scipy.stats import norm
DF = 5 # graus de liberdade da t (deriva e viés), o mesmo DF_ERRO do modelo atual
K_T = (DF - 2) / DF # escala da t com o mesmo desvio da normal (como _t_unit)
SIG_P_PISO, SIG_P_SD, M_PRIOR = 0.8, 0.5, (math.log(2.0), 0.5)
KAPPA = 1e-6 # variância da "observação" Σ q_p h_p = 0
def _monta(y, dia, se2, inst, n_dias, mu_obs, ancora, q_ancora, prior_h, tau_h, extras, sd_vies, x0_sd, a_h=None):
"""Pré-calcula a matriz de desenho e as partes fixas da precisão. Vetor z = [x_0 … x_{D−1}, β, h_1 … h_P]."""
insts = list(dict.fromkeys(inst))
P, D = len(insts), n_dias
com_vies = sd_vies > 0
iB = D if com_vies else None
i0h = D + (1 if com_vies else 0)
K = i0h + P
pidx = np.array([insts.index(p) for p in inst])
n, ne = len(y), len(extras)
H = np.zeros((n + ne, K))
H[np.arange(n), dia] = 1.0
if com_vies:
H[:n, iB] = 1.0
a_h = np.ones(n) if a_h is None else np.asarray(a_h, float) # persistência do efeito de casa (λ nas pesquisas pós-1º turno)
H[np.arange(n), i0h + pidx] = a_h
yy = np.concatenate([np.asarray(y, float) - mu_obs, [e["valor"] - e.get("mu", 0.0) for e in extras]])
r_extra = np.array([e["sd"] ** 2 for e in extras])
for j, e in enumerate(extras):
H[n + j, e["dia"]] = 1.0
if e.get("com_vies") and com_vies:
H[n + j, iB] = 1.0
# prior fixo: efeitos de casa, restrição da âncora, x_0
Q_fix = np.zeros((K, K)); b_fix = np.zeros(K)
m_h = np.array([prior_h.get(p, 0.0) for p in insts]); t2 = tau_h ** 2
Q_fix[i0h + np.arange(P), i0h + np.arange(P)] += 1.0 / t2
b_fix[i0h:] += m_h / t2
a = np.zeros(K)
for j, p in enumerate(insts):
if p in ancora:
a[i0h + j] = q_ancora.get(p, 1.0)
if not a.any():
a[i0h:] = 1.0
Q_fix += np.outer(a, a) / KAPPA
x0_m = float(np.mean(np.sort(np.asarray(y, float)[np.argsort(dia)][:10]))) # média das primeiras ondas, prior difuso
Q_fix[0, 0] += 1.0 / x0_sd ** 2; b_fix[0] += x0_m / x0_sd ** 2
return {"H": H, "yy": yy, "n": n, "r_extra": r_extra, "pidx": pidx, "insts": insts, "P": P, "D": D, "K": K, "iB": iB,
"i0h": i0h, "Q_fix": Q_fix, "b_fix": b_fix, "se2": np.asarray(se2, float), "dia": np.asarray(dia, int), "x0_sd": x0_sd, "a_h": a_h}
def _var_passos(D, dia_t1, sd_quebra, s_rw, s_entre):
"""Variância de η_d para d = 1 … D−1: σ_rw² até o 1º turno, + quebra no dia seguinte, σ_entre² depois."""
d = np.arange(1, D)
v = np.where(d <= dia_t1 + 1, s_rw ** 2, s_entre ** 2)
if sd_quebra > 0 and 1 <= dia_t1 + 1 < D:
v[dia_t1] += sd_quebra ** 2
return v
def _posterior(M, v, sig_p, omega, s_b):
"""Precisão e vetor canônico da normal conjunta de z dados os hiperparâmetros; devolve (L, b, log p(y|θ) a menos de constante)."""
D, n = M["D"], M["n"]
r = np.concatenate([M["se2"] + sig_p[M["pidx"]] ** 2, M["r_extra"]])
w = 1.0 / r
Hw = M["H"] * w[:, None]
Q = M["Q_fix"] + M["H"].T @ Hw
b = M["b_fix"] + Hw.T @ M["yy"]
iv = 1.0 / v
di = np.arange(D)
diag = np.zeros(D); diag[1:] += iv; diag[:-1] += iv
Q[di, di] += diag
Q[di[1:], di[:-1]] -= iv; Q[di[:-1], di[1:]] -= iv
if M["iB"] is not None:
Q[M["iB"], M["iB"]] += omega / s_b ** 2
L = cholesky(Q, lower=True, check_finite=False)
u = solve_triangular(L, b, lower=True, check_finite=False)
# log p(y|θ) = ½log|Q0| − ½log|Q| + ½ bᵀQ⁻¹b − ½ Σ log r − ½ yᵀR⁻¹y (+ constantes); |Q0| só tem a parte de x e de β que varia
ll = (-0.5 * np.sum(np.log(v)) - np.sum(np.log(np.diag(L))) + 0.5 * u @ u - 0.5 * np.sum(np.log(r)) - 0.5 * np.sum(w * M["yy"] ** 2))
if M["iB"] is not None:
ll += 0.5 * math.log(omega)
return L, u, ll
def _lp_logsig_p(ls, m):
"""Log-prior de log σ_p: normal truncada em σ_p ≥ piso (o termo de truncamento depende de m)."""
lo = math.log(SIG_P_PISO)
if np.any(ls < lo):
return -np.inf
return float(np.sum(-0.5 * ((ls - m) / SIG_P_SD) ** 2) - len(ls) * norm.logsf((lo - m) / SIG_P_SD))
def _cadeia(M, rng, n_aquec, n_amostras, dia_t1, sd_quebra, prior_rw, prior_entre, sd_vies, n_fut_rw, n_fut_entre, entre_tem_dados, prior_mu_der=(0.0, 0.0)):
D, P = M["D"], M["P"]
s_b = sd_vies * math.sqrt(K_T) if sd_vies > 0 else 1.0
lrw = prior_rw[0] + 0.3 * rng.standard_normal()
lent = prior_entre[0] + 0.3 * rng.standard_normal()
ls = np.log(np.maximum(2.0 * np.exp(0.2 * rng.standard_normal(P)), SIG_P_PISO + 0.05))
m = M_PRIOR[0]; omega = 1.0
passo = {"rw": 0.25, "entre": 0.3, "m": 0.3}; passo_p = np.full(P, 0.4)
ac = {"rw": [], "entre": [], "m": []}; ac_p = []
tot = n_aquec + n_amostras
out = {k: np.empty(n_amostras) for k in ["x_hoje", "x_ultimo", "x_final", "sig_rw", "sig_entre", "beta", "deriva", "m_sigp", "mu_der"]}
out["h"] = np.empty((n_amostras, P)); out["sig_p"] = np.empty((n_amostras, P)); out["x"] = np.empty((n_amostras, D))
lp_rw = lambda z: -0.5 * ((z - prior_rw[0]) / prior_rw[1]) ** 2
lp_en = lambda z: -0.5 * ((z - prior_entre[0]) / prior_entre[1]) ** 2
for it in range(tot):
sp = np.exp(ls)
L, u, ll = _posterior(M, _var_passos(D, dia_t1, sd_quebra, math.exp(lrw), math.exp(lent)), sp, omega, s_b)
# 1a. σ_rw (z integrado)
z_ = lrw + passo["rw"] * rng.standard_normal()
L2, u2, ll2 = _posterior(M, _var_passos(D, dia_t1, sd_quebra, math.exp(z_), math.exp(lent)), sp, omega, s_b)
aceita = math.log(rng.random()) < ll2 + lp_rw(z_) - ll - lp_rw(lrw)
if aceita:
lrw, L, u, ll = z_, L2, u2, ll2
ac["rw"].append(aceita)
# 1b. σ_entre: só tem verossimilhança se houver dias observados depois do 1º turno; senão, sorteio direto do prior
if entre_tem_dados:
z_ = lent + passo["entre"] * rng.standard_normal()
L2, u2, ll2 = _posterior(M, _var_passos(D, dia_t1, sd_quebra, math.exp(lrw), math.exp(z_)), sp, omega, s_b)
aceita = math.log(rng.random()) < ll2 + lp_en(z_) - ll - lp_en(lent)
if aceita:
lent, L, u, ll = z_, L2, u2, ll2
ac["entre"].append(aceita)
else:
lent = prior_entre[0] + prior_entre[1] * rng.standard_normal()
# 2. (x, β, h) em bloco: z = Q⁻¹b + L⁻ᵀε
zv = solve_triangular(L.T, u + rng.standard_normal(M["K"]), lower=False, check_finite=False)
x = zv[:D]; beta = zv[M["iB"]] if M["iB"] is not None else 0.0; h = zv[M["i0h"]:]
# 3. σ_p por instituto (Metropolis vetorizado, dado o resíduo)
res = M["yy"][:M["n"]] - x[M["dia"]] - beta - M["a_h"] * h[M["pidx"]]
def ll_p(lsv):
r_ = M["se2"] + np.exp(lsv)[M["pidx"]] ** 2
return np.bincount(M["pidx"], weights=-0.5 * np.log(r_) - 0.5 * res ** 2 / r_, minlength=P)
lo = math.log(SIG_P_PISO)
prop = ls + passo_p * rng.standard_normal(P)
lp_c = -0.5 * ((ls - m) / SIG_P_SD) ** 2; lp_n = np.where(prop >= lo, -0.5 * ((prop - m) / SIG_P_SD) ** 2, -np.inf)
a_p = np.log(rng.random(P)) < ll_p(prop) + lp_n - ll_p(ls) - lp_c
ls = np.where(a_p, prop, ls); ac_p.append(a_p)
# 3b. m (centro hierárquico de log σ_p)
m_ = m + passo["m"] * rng.standard_normal()
lm = lambda mm: _lp_logsig_p(ls, mm) - 0.5 * ((mm - M_PRIOR[0]) / M_PRIOR[1]) ** 2
aceita = math.log(rng.random()) < lm(m_) - lm(m)
if aceita:
m = m_
ac["m"].append(aceita)
# 4. ω | β (t₅ como mistura de escala de normais)
if M["iB"] is not None:
omega = rng.gamma(DF / 2 + 0.5, 1.0 / (DF / 2 + beta ** 2 / (2 * s_b ** 2)))
# adaptação dos passos no aquecimento (alvo ~0,44 em uma dimensão)
if it < n_aquec and (it + 1) % 50 == 0:
for k in passo:
if ac[k]:
passo[k] *= math.exp(np.mean(ac[k][-50:]) - 0.44)
passo_p *= np.exp(np.mean(ac_p[-50:], axis=0) - 0.44)
if it >= n_aquec:
j = it - n_aquec
# previsão: deriva até 25/out com caudas t₅ e o mesmo desvio da versão normal
v_fut = n_fut_rw * math.exp(lrw) ** 2 + n_fut_entre * math.exp(lent) ** 2
om_d = rng.gamma(DF / 2, 2.0 / DF)
# média da deriva (variante com momentum): sorteada do prior a cada iteração, sem verossimilhança própria (como σ_entre sem dados)
mu_d = prior_mu_der[0] + prior_mu_der[1] * rng.standard_normal() if prior_mu_der[1] > 0 else prior_mu_der[0]
der = mu_d + math.sqrt(v_fut * K_T / om_d) * rng.standard_normal()
out["x_ultimo"][j] = x[-1]; out["x_final"][j] = x[-1] + der; out["deriva"][j] = der; out["mu_der"][j] = mu_d
out["sig_rw"][j] = math.exp(lrw); out["sig_entre"][j] = math.exp(lent); out["beta"][j] = beta
out["m_sigp"][j] = math.exp(m); out["h"][j] = h; out["sig_p"][j] = np.exp(ls); out["x"][j] = x
taxa = {k: round(float(np.mean(v[n_aquec:])), 3) for k, v in ac.items() if len(v) > n_aquec}
taxa["sig_p_media"] = round(float(np.mean(ac_p[n_aquec:])), 3)
return out, taxa
def amostrar(y, dia, se2, inst, n_dias, ancora, q_ancora=None, mu_obs=None, sd_vies=0.0, prior_h=None, tau_h=1.5,
dia_t1=10 ** 9, sd_quebra=0.0, extras=(), prior_rw=(math.log(0.25), 0.6), prior_entre=(math.log(0.25), 0.5),
n_fut_rw=0, n_fut_entre=0, dia_hoje=None, n_cadeias=4, n_aquec=1500, n_amostras=4000, seed=20261025, x0_sd=10.0, a_h=None,
prior_mu_der=(0.0, 0.0)):
"""
y, dia, se2, inst: pesquisas (dia inteiro desde a origem da janela). n_dias: nº de dias do estado (0 … n_dias−1).
mu_obs: desconto de viés esperado de cada pesquisa (antes/depois do 1º turno). sd_vies: desvio do erro comum β (0 = sem β).
dia_t1: índice do dia do 1º turno; dia_t1+1 recebe a quebra sd_quebra² e os dias seguintes usam σ_entre.
extras: observações diretas do estado, dicts {dia, valor, sd, com_vies, mu} (nível estrutural, leitura estadual).
n_fut_rw / n_fut_entre: dias entre o último dia do estado e 25/out em cada regime de deriva. dia_hoje: dia reportado como "hoje".
prior_mu_der: (média, desvio) da média da deriva até 25/out; (0, 0) = deriva centrada em zero (modelo principal).
Retorna dict com as amostras (cadeias × sorteios), taxas de aceitação e diagnósticos.
"""
q_ancora = q_ancora or {}; prior_h = prior_h or {}
mu_obs = np.zeros(len(y)) if mu_obs is None else np.asarray(mu_obs, float)
M = _monta(np.asarray(y, float), np.asarray(dia, int), se2, np.asarray(inst), n_dias, mu_obs, set(ancora), q_ancora, prior_h,
tau_h, list(extras), sd_vies, x0_sd, a_h)
entre_tem_dados = n_dias - 1 > dia_t1 + 1
dia_hoje = n_dias - 1 if dia_hoje is None else dia_hoje
cad, taxas = [], []
for c in range(n_cadeias):
o, t = _cadeia(M, np.random.default_rng([seed, c]), n_aquec, n_amostras, dia_t1, sd_quebra, prior_rw, prior_entre, sd_vies,
n_fut_rw, n_fut_entre, entre_tem_dados, prior_mu_der)
o["x_hoje"] = o["x"][:, dia_hoje]
cad.append(o); taxas.append(t)
S = {k: np.stack([o[k] for o in cad]) for k in cad[0]}
diag = {k: {"rhat": round(rhat(S[k]), 4), "ess": int(ess(S[k]))} for k in ["x_hoje", "x_final", "sig_rw", "sig_entre", "beta", "m_sigp"] + (["mu_der"] if prior_mu_der[1] > 0 else [])}
for j, p in enumerate(M["insts"]):
diag[f"h[{p}]"] = {"rhat": round(rhat(S["h"][:, :, j]), 4), "ess": int(ess(S["h"][:, :, j]))}
diag[f"sig_p[{p}]"] = {"rhat": round(rhat(S["sig_p"][:, :, j]), 4), "ess": int(ess(S["sig_p"][:, :, j]))}
return {"amostras": S, "insts": M["insts"], "aceitacao": taxas, "diagnostico": diag,
"rhat_max": max(d["rhat"] for d in diag.values()), "ess_min": min(d["ess"] for d in diag.values()),
"n_cadeias": n_cadeias, "n_aquec": n_aquec, "n_amostras": n_amostras, "entre_tem_dados": bool(entre_tem_dados)}
# ---------------- diagnósticos ----------------
def _split(x):
x = np.asarray(x, float)
h = x.shape[1] // 2
return np.concatenate([x[:, :h], x[:, h:2 * h]])
def rhat(x):
"""R̂ dividido (Gelman et al., BDA3): cadeias × sorteios."""
s = _split(x)
m, n = s.shape
W = s.var(axis=1, ddof=1).mean()
B = n * s.mean(axis=1).var(ddof=1)
if W <= 0:
return 1.0
return float(math.sqrt(((n - 1) / n * W + B / n) / W))
def ess(x):
"""Tamanho efetivo de amostra multicadeia (Geyer: soma de pares positivos e monótonos de autocorrelação)."""
s = _split(x)
m, n = s.shape
if s.var() == 0:
return float(m * n)
xc = s - s.mean(axis=1, keepdims=True)
f = np.fft.rfft(xc, n=2 * n, axis=1)
acov = np.fft.irfft(f * np.conj(f), axis=1)[:, :n] / n
W = s.var(axis=1, ddof=1).mean()
var_mais = (n - 1) / n * W + s.mean(axis=1).var(ddof=1)
rho = 1.0 - (W - acov.mean(axis=0)) / var_mais
rho[0] = 1.0
soma, prev = 0.0, np.inf
for t in range(0, n - 1, 2):
par = rho[t] + rho[t + 1]
if par < 0:
break
par = min(par, prev); prev = par
soma += par
tau = -1.0 + 2.0 * soma
return float(m * n / max(tau, 1.0 / math.log10(m * n)))
# ---------------- verificação ----------------
if __name__ == "__main__":
import sys, os, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from espaco_estados import _kalman
# (a) com hiperparâmetros fixos, sem β e sem efeitos de casa, a normal conjunta coincide com o suavizador RTS do filtro de Kalman
rng = np.random.default_rng(1)
D = 80; xs_true = np.cumsum(np.r_[2.0, 0.3 * rng.standard_normal(D - 1)])
dd = np.sort(rng.integers(0, D, 40)); se2 = np.full(40, 4.0); yv = xs_true[dd] + 2 * rng.standard_normal(40)
M = _monta(yv, dd, se2, np.array(["A"] * 40), D, np.zeros(40), {"A"}, {}, {}, 1.5, [], 0.0, 10.0)
M["b_fix"][0] = yv[0] / 100.0 # mesmo x_0 do filtro: N(y_0, 100)
L, u, _ = _posterior(M, np.full(D - 1, 0.3 ** 2), np.array([1.0]), 1.0, 1.0)
mean = solve_triangular(L.T, u, lower=False)[:D]
Linv = solve_triangular(L, np.eye(M["K"]), lower=True); var = (Linv ** 2).sum(axis=0)[:D]
_, xs_k, Ps_k, _, _ = _kalman(yv, dd, se2 + 1.0, 0.3, D, yv[0], keep=True)
assert np.allclose(mean, xs_k, atol=1e-4), np.abs(mean - xs_k).max()
assert np.allclose(var, Ps_k, atol=1e-4), np.abs(var - Ps_k).max()
print(f"[ok] normal conjunta = suavizador RTS (dif. máx. média {np.abs(mean - xs_k).max():.1e}, variância {np.abs(var - Ps_k).max():.1e})")
# (b) recuperação de parâmetros em dados simulados: casas, σ_rw, σ_p e o nível final
rng = np.random.default_rng(7)
D = 120; srw = 0.2; casas = {"A": 0.0, "B": 0.0, "C": 2.5, "D": -2.0, "E": 1.0}; sigp = {"A": 1.0, "B": 1.0, "C": 3.0, "D": 1.5, "E": 1.0}
xt = np.cumsum(np.r_[-1.0, srw * rng.standard_normal(D - 1)])
rows = [(d, p) for d in range(0, D, 2) for p in casas if rng.random() < 0.35]
dd = np.array([d for d, _ in rows]); ii = np.array([p for _, p in rows]); se2 = np.full(len(rows), 2.2 ** 2)
yv = xt[dd] + np.array([casas[p] for p in ii]) + np.sqrt(se2 + np.array([sigp[p] ** 2 for p in ii])) * rng.standard_normal(len(rows))
t0 = time.time()
R = amostrar(yv, dd, se2, ii, D, {"A", "B"}, n_cadeias=4, n_aquec=800, n_amostras=1500, seed=3)
S = R["amostras"]
def cobre(a, v, lo=5, hi=95): q_ = np.percentile(a, [lo, hi]); return q_[0] <= v <= q_[1]
assert cobre(S["sig_rw"], srw), np.percentile(S["sig_rw"], [5, 95])
assert cobre(S["x_hoje"], xt[-1]), (np.percentile(S["x_hoje"], [5, 95]), xt[-1])
for j, p in enumerate(R["insts"]):
assert cobre(S["h"][:, :, j], casas[p] - (casas["A"] + casas["B"]) / 2), (p, np.percentile(S["h"][:, :, j], [5, 95]))
assert R["rhat_max"] < 1.05, R["rhat_max"]
print(f"[ok] recuperação: σ_rw {np.median(S['sig_rw']):.3f} (verdadeiro {srw}), nível final {np.median(S['x_hoje']):+.2f} "
f"(verdadeiro {xt[-1]:+.2f}), R̂ máx {R['rhat_max']:.3f}, ESS mín {R['ess_min']}, {len(rows)} pesquisas, {time.time() - t0:.1f} s")
# -*- coding: utf-8 -*-
"""
Migração de ausentes, terceira via e brancos/nulos entre o 1º e o 2º turno: cadeia de Markov + Monte Carlo (análise paralela).
Pergunta: para onde vão, até 25/out, os 33,5 mi que não votaram em 4/out, os 9,3 mi que votaram na terceira via e os 6,0 mi de
brancos e nulos, e quanto uma campanha de conversa pessoal do PT com esses grupos (guia "Três nomes, três semanas") poderia mudar.
Estrutura (tudo em votos absolutos, por UF + exterior, a partir do resultado oficial em resultado_1turno_uf.csv):
1. Estado inicial n_u = (Lula, Flávio, Cury, Renan, Caiado, Zema+outros, brancos+nulos, abstenção), Σ = eleitorado_u.
2. Matriz de um passo M_u (8 origens × 4 destinos: Lula, Flávio, branco/nulo, não vota), uma por sorteio e por UF: cada linha é
Dirichlet(κ·m) em torno de um centro m sorteado por fora (2ª opção declarada da Quaest; decisão dos "sem 2ª opção" e dos
brancos/nulos; fluxos brutos de abstenção: r_out sai, r_in entra, com inclinação de perfil τ). destinos_u = n_u · M_u.
3. Cadeia de Markov da campanha (5 a 24/out, não homogênea, fases do guia): por bolsão (ausentes, terceira via + brancos/nulos,
eleitor brando de Flávio), estados S0 não alcançado · S1 alcançado · S2 convertido (plano de voto) · S3 confirmado · S4 recusou.
O alcance vem do nº de voluntários (V_0 inicial, cada um convida 3 com aceitação p_ac, até 3 gerações, teto ψ·votos de Lula);
a conversão por contato q vem da literatura de mobilização (Gerber & Green; Nickerson & Rogers; Kalla & Broockman). O estoque
convertido é repartido pelas UFs ∝ voto de Lula e aplicado como fluxo entre destinos, com teto na massa disponível.
4. Cenários: escada de alcance (sem campanha dirigida / moderada / forte / máxima) × resposta de Flávio (nenhuma / simétrica, rede
ρ× a do PT e os mesmos priors de eficácia) + referência "metas do guia cumpridas". Números aleatórios comuns entre células.
5. Saídas: margem Lula − Flávio em válidos, abstenção e brancos/nulos do 2º turno, saldo por frente, trajetória diária, por região
e por UF, matriz de transição mediana; P(Lula) "só migração" e "com deriva" (a deriva t₅ do modelo principal, para comparar).
Não é previsão: é análise condicional. A manchete continua sendo o modelo principal. Autoverificações: python migracao_markov.py
"""
import json, math, os, time, html as H
import numpy as np
import pandas as pd
ORIG = ["lula", "flavio", "cury", "renan", "caiado", "zo", "bn", "abs"]
DEST = ["lula", "flavio", "bn", "abs"]
TV = ["cury", "renan", "caiado", "zo"]
ROT_ORIG = {"lula": "Votou em Lula", "flavio": "Votou em Flávio", "cury": "Votou em Cury", "renan": "Votou em Renan", "caiado": "Votou em Caiado",
"zo": "Votou em Zema ou outros", "bn": "Votou branco ou nulo", "abs": "Não votou"}
ROT_DEST = {"lula": "Lula", "flavio": "Flávio", "bn": "Branco/nulo", "abs": "Não vota"}
REGIAO_NOME = {"N": "Norte", "NE": "Nordeste", "CO": "Centro-Oeste", "SE": "Sudeste", "S": "Sul", "EXT": "Exterior"}
KAPPA = {"lula": 400, "flavio": 400, "cury": 100, "renan": 100, "caiado": 100, "zo": 100, "bn": 150, "abs": 150}
INICIO, FIM, T2 = pd.Timestamp("2026-10-05"), pd.Timestamp("2026-10-24"), pd.Timestamp("2026-10-25")
DIAS = pd.date_range(INICIO, FIM) # 20 dias de campanha
# fases do guia: (nome, início, fim, multiplicador do hazard de conversão, hazard de confirmação/dia, hazard de encerramento/dia, geração recrutada)
FASES = [("Largada", "2026-10-05", "2026-10-06", 0.0, 0.0, 0.0, 0),
("1ª conversa", "2026-10-07", "2026-10-11", 0.8, 0.0, 0.0, 1),
("Pausa (Aparecida)", "2026-10-12", "2026-10-12", 0.0, 0.0, 0.0, None),
("2ª conversa", "2026-10-13", "2026-10-18", 1.4, 0.0, 0.0, 2),
("Fechamento", "2026-10-19", "2026-10-22", 0.8, 0.15, 0.3, 3),
("Confirmação", "2026-10-23", "2026-10-24", 0.0, 0.4, 0.3, None)]
BOLSOES = ["ausentes", "terceira_via", "flavio_brando"]
REALIZA = {"ausentes": (0.85, 0.55), "terceira_via": (0.90, 0.55), "flavio_brando": (0.90, 0.55)} # P(vota como combinado | S3), (| S2)
CENARIOS = [("sem", "Sem campanha dirigida", 0.0, 0.0), ("moderada", "Moderada", 100_000, 0.5),
("forte", "Forte", 500_000, 1.0), ("maxima", "Máxima", 2_000_000, 1.5)]
RESPOSTAS = [("nenhuma", "Nenhuma"), ("simetrica", "Simétrica")]
GUIA_METAS = {"terceira_via": 2.4e6, "eleitores_flavio": 1.2e6, "brancos_nulos": 1.0e6, "ausentes": 0.5e6} # saldo (L − F) por frente
DF_T = 5
BINS = np.arange(-8.0, 4.01, 0.25) # histograma da margem só pela migração (caudas somadas nas pontas)
# (nome, símbolo, distribuição, média, desvio, fonte)
PRIORS = [
("Troca direta entre Lula e Flávio", "ε_x", "Beta", 0.005, 0.003, "prior explícito: troca direta rara entre os dois"),
("Eleitor de Lula ou Flávio que vota branco/nulo", "ε_bn", "Beta", 0.01, 0.005, "prior explícito"),
("Votou no 1º turno e falta no 2º", "r_out", "Beta", 0.05, 0.015, "cerca de 6 mi; em 2022 a abstenção quase não mudou entre os turnos, com troca de pessoas"),
("Variação líquida nacional da abstenção (p.p.)", "δ_nac", "Normal", 0.5, 1.0, "a abstenção subiu no 2º turno em 2006–2018 e caiu em 2022"),
("Variação líquida por UF além do padrão de 2022 (p.p.)", "δ_u", "Normal", 0.0, 0.7, "padrão 1º→2º turno de 2022 por UF encolhido 50% e cortado em ±3 (abstencao_2022_uf.csv)"),
("Inclinação de perfil de quem entra ou sai (p.p. de margem pró-Lula)", "τ", "Normal, ≥ 0", 10.0, 5.0, "abstenção por escolaridade 2022 (27/17/13%) × voto por escolaridade (Quaest 3/out) ≈ +6; K = 0,25 da seção Abstenção ≈ 20"),
("\"Sem 2ª opção\" da 3ª via que escolhem um dos dois", "d_3", "Beta", 0.50, 0.12, "modelo estrutural: pool decidido a 55%"),
("Parcela de Lula entre os que escolhem", "s_3", "Beta", 0.50, 0.08, "faixa 35–65% do tornado"),
("Brancos e nulos do 1º turno que escolhem um dos dois", "d_bn", "Beta", 0.35, 0.10, "TSE 2022: brancos + nulos caíram de 7,0 mi no 1º turno para 5,7 mi no 2º"),
("Brancos e nulos que faltam no 2º turno", "a_bn", "Beta", 0.08, 0.03, "prior explícito"),
("Aceitação do convite para montar a própria lista", "p_ac", "Beta", 0.25, 0.10, "organização relacional: adesão de 1 em 4 convidados"),
("Teto de voluntários (fração dos votos de Lula)", "ψ", "Beta", 0.05, 0.02, "2,7 mi no centro, ordem de grandeza dos filiados do PT"),
("Conversão por contato: ausentes", "q_aus", "Beta", 0.12, 0.05, "Gerber & Green (porta a porta: 1 voto por ~14 contatos); Nickerson & Rogers (plano de voto: +4 p.p.); organização relacional: +5 a +10 p.p."),
("Conversão por contato: 3ª via e brancos/nulos", "q_tv", "Beta", 0.05, 0.03, "Kalla & Broockman 2018: persuasão ≈ 0 em eleição geral, não nula entre indecisos; só quem não tem 2ª opção é permeável"),
("Conversão por contato: eleitor brando de Flávio", "q_fb", "Beta", 0.02, 0.015, "idem, com o voto já dado ao adversário"),
("Eleitores brandos (fração do voto de cada candidato)", "f_br", "Beta", 0.12, 0.04, "guia: 600 mil \"pouco convictos\" é o piso"),
("Reversão diária de convertido para alcançado", "δ_rev", "Beta", 0.03, 0.01, "plano de voto que não se cumpre"),
("Parcela de Lula entre ausentes trazidos nominalmente", "ν", "Beta", 0.80, 0.07, "trabalho nominal: só a lista do voluntário"),
("Chamado aberto: queda dos ausentes nas UFs de Lula (× intensidade)", "ω", "Beta", 0.015, 0.01, "Ipea 2023: passe livre no 2º turno de 2022 sem efeito mensurável; teto baixo"),
("Rede de Flávio ÷ rede do PT (resposta simétrica)", "ρ", "Log-normal", 0.6, 0.4, "mesmos priors de eficácia, menos capilaridade"),
("Voluntários iniciais (dispersão em torno do cenário)", "V_0", "Log-normal", None, 0.5, "desconhecido: a escada de cenários vai de 0 a 2 mi"),
]
def priors():
return [{"nome": n, "simbolo": s, "dist": d, "media": m, "sd": sd, "fonte": f} for n, s, d, m, sd, f in PRIORS]
def _p(simbolo):
for n, s, d, m, sd, f in PRIORS:
if s == simbolo: return m, sd
raise KeyError(simbolo)
def _beta(rng, mu, sd, size=None):
k = mu*(1 - mu)/sd**2 - 1
assert k > 0, (mu, sd)
return rng.beta(mu*k, (1 - mu)*k, size)
def _dirichlet(rng, centro, kappa):
g = rng.gamma(np.maximum(kappa*centro, 1e-6))
return g/g.sum(axis=-1, keepdims=True)
def _t_unit(rng, n):
return rng.standard_t(DF_T, n)*math.sqrt((DF_T - 2)/DF_T)
# ------------------------------------------------------------------ dados
def disponivel(DIR):
return all(os.path.exists(DIR + a) for a in ("resultado_1turno_uf.csv", "referencia_uf.csv", "abstencao_2022_uf.csv"))
def carregar(DIR):
r = pd.read_csv(DIR + "resultado_1turno_uf.csv")
br = r[r.uf == "BR"].iloc[0]
r = r[r.uf != "BR"].reset_index(drop=True)
ref = pd.read_csv(DIR + "referencia_uf.csv")[["uf", "nome", "regiao"]]
ab = pd.read_csv(DIR + "abstencao_2022_uf.csv")[["uf", "abst_1t_2022", "abst_2t_2022"]]
r = r.merge(ref, on="uf", how="left").merge(ab, on="uf", how="left")
r["regiao"] = r.regiao.fillna("EXT"); r["nome"] = r.nome.fillna("Exterior")
n = np.column_stack([r.lula, r.flavio, r.cury, r.renan, r.caiado, r.zema + r.outros, r.brancos + r.nulos, r.abstencao]).astype(float)
return {"tab": r, "n": n, "br": br, "ufs": list(r.uf), "regiao": list(r.regiao), "nome": list(r.nome),
"eleitorado": n.sum(1), "eleitorado_tse": r.eleitorado.values.astype(float), "comparecimento": r.comparecimento.values.astype(float), # Σ das caixas (a tabela do TSE difere do eleitorado em poucas centenas em algumas UFs)
"pad22": np.clip(0.5*(r.abst_2t_2022 - r.abst_1t_2022).fillna(0.0).values, -3, 3),
"zema": r.zema.values.astype(float), "outros": r.outros.values.astype(float), "ext": np.array([u == "EXT" for u in r.uf])}
def _transf_uf(D, transf):
"""(pF, pL) por origem; Zema e outros fundidos com peso pelos votos de cada UF. Retorna dict origem → (pF (U,), pL (U,))."""
U = len(D["ufs"]); out = {}
for o in ("cury", "renan", "caiado"):
pf, pl = transf[o]; out[o] = (np.full(U, pf), np.full(U, pl))
z, x = D["zema"], D["outros"]; w = np.where(z + x > 0, z/np.maximum(z + x, 1), 0.5)
out["zo"] = (w*transf["zema"][0] + (1 - w)*transf["outros"][0], w*transf["zema"][1] + (1 - w)*transf["outros"][1])
for o, (pf, pl) in out.items():
assert np.all(pf + pl <= 1.0 + 1e-9), (o, "2ª opção soma mais de 100%")
return out
def estrutural_check(D, transf, decis=0.55, split=0.5):
"""Reprodução determinística do modelo estrutural: 2ª opção nos centros, pool (sem 2ª opção + brancos/nulos) decidido a 55% e dividido 50/50."""
n, tr = D["n"], _transf_uf(D, transf)
L, F = n[:, 0].sum(), n[:, 1].sum(); pool = n[:, 6].sum()
for j, o in enumerate(TV):
pf, pl = tr[o]; v = n[:, 2 + j]
L += (v*pl).sum(); F += (v*pf).sum(); pool += (v*(1 - pf - pl)).sum()
L += pool*decis*split; F += pool*decis*(1 - split)
return 100*(L - F)/(L + F)
# ------------------------------------------------------------------ matriz de um passo
def sortear_base(rng, N, U):
"""Parâmetros nacionais por sorteio (comuns a todas as UFs e a todas as células)."""
B = {}
for k, s in [("eps_x", "ε_x"), ("eps_bn", "ε_bn"), ("r_out", "r_out"), ("d3", "d_3"), ("s3", "s_3"), ("d_bn", "d_bn"), ("a_bn", "a_bn"),
("p_ac", "p_ac"), ("psi", "ψ"), ("q_aus", "q_aus"), ("q_tv", "q_tv"), ("q_fb", "q_fb"), ("f_br", "f_br"), ("d_rev", "δ_rev"),
("nu", "ν"), ("omega", "ω")]:
m, sd = _p(s); B[k] = _beta(rng, m, sd, N)
for k, s in [("q_aus_F", "q_aus"), ("q_tv_F", "q_tv"), ("q_fb_F", "q_fb")]: # eficácia da resposta de Flávio: mesmos priors, sorteio próprio
m, sd = _p(s); B[k] = _beta(rng, m, sd, N)
m, sd = _p("δ_nac"); B["delta_nac"] = rng.normal(m, sd, N)
m, sd = _p("δ_u"); B["delta_u"] = rng.normal(m, sd, (N, U))
m, sd = _p("τ"); B["tau"] = np.clip(rng.normal(m, sd, N), 0.0, None)
m, sd = _p("ρ"); B["rho"] = np.exp(math.log(m) + sd*rng.standard_normal(N))
B["z_v0"] = rng.standard_normal(N)
return B
def matriz(D, B, transf, rng, dirichlet=True):
"""Centros m (N, U, 8, 4) e, se dirichlet, linhas Dirichlet(κ·m) em torno deles. Devolve (M, extras)."""
n = D["n"]; U, N = n.shape[0], len(B["r_out"])
L, F, BN, ABS = n[:, 0], n[:, 1], n[:, 6], n[:, 7]
s_u = L/(L + F)
tr = _transf_uf(D, transf)
bc = lambda x: np.broadcast_to(np.asarray(x, float).reshape(N, 1), (N, U))
r = bc(B["r_out"]); th = bc(np.minimum(B["tau"]/100, 0.9)); ex, eb = bc(B["eps_x"]), bc(B["eps_bn"])
d3, s3, dbn, abn = bc(B["d3"]), bc(B["s3"]), bc(B["d_bn"]), bc(B["a_bn"])
rL, rF = r*(1 + th), r*(1 - th) # quem sai é mais pró-Lula (mesma τ da entrada): sem variação líquida, efeito ≈ 0
M = np.zeros((N, U, 8, 4))
M[:, :, 0] = np.stack([1 - ex - eb - rL, ex, eb, rL], -1)
M[:, :, 1] = np.stack([ex, 1 - ex - eb - rF, eb, rF], -1)
perm = {} # massas permeáveis (centros) por origem e destino, para tetos e fluxos da campanha
for j, o in enumerate(TV):
pf, pl = tr[o]; res = np.clip(1 - pf - pl, 0, 1)[None, :]
M[:, :, 2 + j] = np.stack([(pl[None, :] + res*d3*s3)*(1 - r), (pf[None, :] + res*d3*(1 - s3))*(1 - r), res*(1 - d3)*(1 - r), r], -1)
base = n[None, :, 2 + j]*res*(1 - r)
perm[o] = {"lula": base*d3*s3, "flavio": base*d3*(1 - s3), "bn": base*(1 - d3)}
M[:, :, 6] = np.stack([dbn*s3*(1 - abn), dbn*(1 - s3)*(1 - abn), (1 - dbn)*(1 - abn), abn], -1)
base = n[None, :, 6]*(1 - abn)
perm["bn"] = {"lula": base*dbn*s3, "flavio": base*dbn*(1 - s3), "bn": base*(1 - dbn)}
# abstenção: meta líquida por UF (padrão 2022 encolhido + nacional + UF) e taxa bruta de entrada derivada
dA = D["pad22"][None, :] + bc(B["delta_nac"]) + B["delta_u"]
r_in = np.clip((r*D["comparecimento"][None, :] - dA*D["eleitorado"][None, :]/100)/ABS[None, :], 0.02, 0.6)
pi = np.clip(s_u[None, :] + bc(B["tau"])/200, 0.0, 1.0)
M[:, :, 7] = np.stack([r_in*0.95*pi, r_in*0.95*(1 - pi), r_in*0.05, 1 - r_in], -1)
assert np.allclose(M.sum(-1), 1.0, atol=1e-9)
if dirichlet:
for j, o in enumerate(ORIG):
M[:, :, j] = _dirichlet(rng, M[:, :, j], KAPPA[o])
return M, {"s_u": s_u, "pi": pi, "perm": perm, "r_in": r_in, "dA": dA}
# ------------------------------------------------------------------ cadeia de Markov da campanha
def _calendario(inicio):
"""Vetores diários: multiplicador de conversão normalizado (Σ = 1 nos dias ativos), hazards de confirmação e encerramento, dias de cada geração."""
T = len(DIAS); mult = np.zeros(T); conf = np.zeros(T); enc = np.zeros(T); ger = {}
for nome, a, b, m, c, e, g in FASES:
idx = [i for i, d in enumerate(DIAS) if pd.Timestamp(a) <= d <= pd.Timestamp(b) and d >= inicio]
for i in idx: mult[i], conf[i], enc[i] = m, c, e
if g is not None: ger[g] = idx
ativos = [i for i, d in enumerate(DIAS) if d >= inicio]
for g in range(4): # geração sem dias ativos (início tardio): entra no primeiro dia ativo
if not ger.get(g): ger[g] = ativos[:1]
w = mult/mult.sum() if mult.sum() > 0 else mult
return w, conf, enc, ger
def voluntarios(V0, p_ac, teto, ger):
"""Voluntários acumulados por dia (N, T): V0 na largada, cada geração = 3·p_ac × a anterior, até 3 gerações, teto ψ·votos."""
N, T = len(V0), len(DIAS); arr = np.zeros((N, T)); g = V0.copy()
for k in range(4):
if k > 0: g = g*3*p_ac
for i in ger[k]: arr[:, i] += g/len(ger[k])
return np.minimum(np.cumsum(arr, axis=1), teto[:, None])
def cadeia(V_t, P, q, d_rev, w, conf, enc):
"""Fracções da cadeia por dia: (T, N, B, 5) com estados S0 não alcançado · S1 alcançado · S2 convertido · S3 confirmado · S4 recusou.
V_t (N, T) voluntários acumulados = nomes por bolsão; P (N, B) tamanho dos bolsões; q (N, B) conversão por contato."""
N, T = V_t.shape; Bn = P.shape[1]
x = np.zeros((N, Bn, 5)); x[:, :, 0] = 1.0
traj = np.zeros((T, N, Bn, 5)); D_prev = np.zeros((N, Bn)); Pp = np.maximum(P, 1.0)
for t in range(T):
Dt = Pp*(1 - np.exp(-V_t[:, t][:, None]/Pp)) # nomes distintos alcançados até t (sorteio com reposição)
novo = np.clip((Dt - D_prev)/Pp, 0, None); D_prev = Dt
f12 = x[:, :, 1]*q*w[t]; f21 = x[:, :, 2]*d_rev[:, None]; f23 = x[:, :, 2]*conf[t]; f14 = x[:, :, 1]*enc[t]
x[:, :, 0] -= novo; x[:, :, 1] += novo - f12 + f21 - f14; x[:, :, 2] += f12 - f21 - f23; x[:, :, 3] += f23; x[:, :, 4] += f14
traj[t] = x
return traj
# ------------------------------------------------------------------ simulação
def _q(a, ps):
return [round(float(v), 3) for v in np.percentile(a, ps)]
def simular(DIR, transf, hoje, sd_deriva, comparacao=None, regiao_uf=None, transf_fonte="", seed=20261025, n_sorteios=20_000, inicio_cadeia=None):
t0 = time.time()
rng = np.random.default_rng(seed)
D = carregar(DIR); n = D["n"]; U = n.shape[0]; N = n_sorteios
hoje = pd.Timestamp(hoje); inicio = max(INICIO, pd.Timestamp(inicio_cadeia)) if inicio_cadeia is not None else INICIO
if regiao_uf:
D["regiao"] = [regiao_uf.get(u, r) for u, r in zip(D["ufs"], D["regiao"])]
B = sortear_base(rng, N, U)
M, X = matriz(D, B, transf, rng)
T_base = n[None, :, :, None]*M # (N, U, 8, 4): massa de cada origem em cada destino
elei = D["eleitorado"]; ext = D["ext"]; dom = ~ext
L1, F1 = n[:, 0], n[:, 1]
w_L = np.where(dom, L1, 0.0); w_L = w_L/w_L.sum(); w_F = np.where(dom, F1, 0.0); w_F = w_F/w_F.sum()
lula_lidera = (X["s_u"] > 0.5) & dom
s3, dbn = B["s3"], B["d_bn"]
# bolsões nacionais (sem o exterior)
P_aus = float(n[dom, 7].sum())
P_tv = float(sum(sum(v[:, dom].sum(1) for v in X["perm"][o].values()).mean() for o in TV + ["bn"])) # resíduo "sem 2ª opção" + brancos/nulos (médio)
_tru = _transf_uf(D, transf)
sem_2a = float(sum((n[:, 2 + j]*np.clip(1 - _tru[o][0] - _tru[o][1], 0, 1)).sum() for j, o in enumerate(TV))) # 3ª via sem 2ª opção declarada
P_fb = B["f_br"]*float(F1[dom].sum()); P_lb = B["f_br"]*float(L1[dom].sum())
w, conf, enc, ger = _calendario(inicio)
deriva = sd_deriva*_t_unit(rng, N) # a mesma deriva (t₅) em todas as células, para comparar com o modelo principal
bins = BINS
PS = [5, 10, 25, 50, 75, 90, 95]
def frentes_lula(T, V_t, qs, I_cen, saldo):
"""Aplica a campanha do PT sobre T (in place). Devolve trajetória da cadeia e nomes alcançados por bolsão."""
P = np.column_stack([np.full(N, P_aus), np.full(N, P_tv), P_fb])
tr = cadeia(V_t, P, np.column_stack(qs), B["d_rev"], w, conf, enc)
fim = tr[-1]
conv = np.column_stack([P[:, b]*(REALIZA[BOLSOES[b]][0]*fim[:, b, 3] + REALIZA[BOLSOES[b]][1]*fim[:, b, 2]) for b in range(3)]) # votos realizados
# ausentes trazidos nominalmente: ABS→L (ν) e ABS→F (1−ν), ∝ voto de Lula, teto na massa que ficaria em casa
mov = np.minimum(conv[:, 0][:, None]*w_L[None, :], 0.9*T[:, :, 7, 3])
T[:, :, 7, 3] -= mov; T[:, :, 7, 0] += B["nu"][:, None]*mov; T[:, :, 7, 1] += (1 - B["nu"])[:, None]*mov
saldo["ausentes"] += ((2*B["nu"] - 1)[:, None]*mov).sum(1)
# terceira via + brancos/nulos: sai dos destinos Flávio e branco/nulo das linhas permeáveis, ∝ massa permeável, → Lula
permF = sum(X["perm"][o]["flavio"] for o in TV + ["bn"]); permB = sum(X["perm"][o]["bn"] for o in TV + ["bn"])
avail = permF + permB
mov = np.minimum(conv[:, 1][:, None]*w_L[None, :], 0.9*avail)
for o in TV + ["bn"]:
j = ORIG.index(o)
mF = np.minimum(mov*X["perm"][o]["flavio"]/np.maximum(avail, 1), 0.9*T[:, :, j, 1]); mB = np.minimum(mov*X["perm"][o]["bn"]/np.maximum(avail, 1), 0.9*T[:, :, j, 2])
T[:, :, j, 1] -= mF; T[:, :, j, 2] -= mB; T[:, :, j, 0] += mF + mB
saldo["terceira_via" if o != "bn" else "brancos_nulos"] += (2*mF + mB).sum(1)
# eleitor brando de Flávio: F→L, teto f_br da massa que ficaria com Flávio
mov = np.minimum(conv[:, 2][:, None]*w_L[None, :], B["f_br"][:, None]*T[:, :, 1, 1])
T[:, :, 1, 1] -= mov; T[:, :, 1, 0] += mov; saldo["eleitores_flavio"] += (2*mov).sum(1)
# chamado aberto: nas UFs em que Lula liderou, ω·I_cen dos ausentes votam (composição π_u); nas de Flávio, 0,3× (sai pela culatra)
inten = np.where(lula_lidera, 1.0, 0.3)[None, :]*np.where(dom, 1.0, 0.0)[None, :]*(B["omega"]*I_cen)[:, None]
e = inten*T[:, :, 7, 3]
T[:, :, 7, 3] -= e; T[:, :, 7, 0] += e*X["pi"]; T[:, :, 7, 1] += e*(1 - X["pi"])
saldo["chamado_aberto"] += (e*(2*X["pi"] - 1)).sum(1)
nomes = np.column_stack([P[:, b]*(1 - np.exp(-V_t[:, -1]/np.maximum(P[:, b], 1))) for b in range(3)])
return tr, nomes, conv
def frentes_flavio(T, V_t, qs, I_cen, saldo):
"""Resposta simétrica de Flávio: mesmos bolsões espelhados, ∝ voto de Flávio, com os mesmos priors de eficácia."""
P = np.column_stack([np.full(N, P_aus), np.full(N, P_tv), P_lb])
tr = cadeia(V_t, P, np.column_stack(qs), B["d_rev"], w, conf, enc)
fim = tr[-1]
conv = np.column_stack([P[:, b]*(REALIZA[BOLSOES[b]][0]*fim[:, b, 3] + REALIZA[BOLSOES[b]][1]*fim[:, b, 2]) for b in range(3)])
s = np.zeros(N)
mov = np.minimum(conv[:, 0][:, None]*w_F[None, :], 0.9*T[:, :, 7, 3])
T[:, :, 7, 3] -= mov; T[:, :, 7, 1] += B["nu"][:, None]*mov; T[:, :, 7, 0] += (1 - B["nu"])[:, None]*mov
s -= ((2*B["nu"] - 1)[:, None]*mov).sum(1)
permL = sum(X["perm"][o]["lula"] for o in TV + ["bn"]); permB = sum(X["perm"][o]["bn"] for o in TV + ["bn"]); avail = permL + permB
mov = np.minimum(conv[:, 1][:, None]*w_F[None, :], 0.9*avail)
for o in TV + ["bn"]:
j = ORIG.index(o)
mL = np.minimum(mov*X["perm"][o]["lula"]/np.maximum(avail, 1), 0.9*T[:, :, j, 0]); mB = np.minimum(mov*X["perm"][o]["bn"]/np.maximum(avail, 1), 0.9*T[:, :, j, 2])
T[:, :, j, 0] -= mL; T[:, :, j, 2] -= mB; T[:, :, j, 1] += mL + mB; s -= (2*mL + mB).sum(1)
mov = np.minimum(conv[:, 2][:, None]*w_F[None, :], B["f_br"][:, None]*T[:, :, 0, 0])
T[:, :, 0, 0] -= mov; T[:, :, 0, 1] += mov; s -= (2*mov).sum(1)
inten = np.where(lula_lidera, 0.3, 1.0)[None, :]*np.where(dom, 1.0, 0.0)[None, :]*(B["omega"]*I_cen)[:, None]
e = inten*T[:, :, 7, 3]
T[:, :, 7, 3] -= e; T[:, :, 7, 0] += e*X["pi"]; T[:, :, 7, 1] += e*(1 - X["pi"])
s += (e*(2*X["pi"] - 1)).sum(1)
saldo["resposta_flavio"] += s
return tr
def agrega(T, cid, nome, rid, rnome, saldo, tr=None, nomes=None, conv=None, V_t=None, V_F=None):
dest = T.sum(axis=2) # (N, U, 4)
Lv, Fv, BNv, ABSv = (dest[:, :, k].sum(1) for k in range(4))
marg = 100*(Lv - Fv)/(Lv + Fv)
md = marg + deriva
c = {"id": cid, "nome": nome, "resposta": rid, "resposta_nome": rnome,
"margem_q": {str(p): round(float(v), 2) for p, v in zip(PS, np.percentile(marg, PS))},
"p_lula_so_migracao": round(float((marg > 0).mean()), 3), "p_lula_com_deriva": round(float((md > 0).mean()), 3),
"margem_com_deriva_q10_50_90": _q(md, [10, 50, 90]),
"abstencao_q10_50_90": _q(100*ABSv/elei.sum(), [10, 50, 90]), "bn_pct_q10_50_90": _q(100*BNv/(Lv + Fv + BNv), [10, 50, 90]),
"lula_mi_q50": round(float(np.median(Lv))/1e6, 3), "flavio_mi_q50": round(float(np.median(Fv))/1e6, 3),
"hist_margem": [round(float(v), 4) for v in np.histogram(np.clip(marg, bins[0] + 1e-6, bins[-1] - 1e-6), bins=bins, density=True)[0]]}
c["saldo_frentes"] = {k: {"q10": round(float(np.percentile(v, 10))), "q50": round(float(np.median(v))), "q90": round(float(np.percentile(v, 90)))} for k, v in saldo.items()}
tot = sum(saldo.values())
c["saldo_frentes"]["total"] = {"q10": round(float(np.percentile(tot, 10))), "q50": round(float(np.median(tot))), "q90": round(float(np.percentile(tot, 90)))}
if nomes is not None:
for b, k in enumerate(BOLSOES):
c["saldo_frentes"][{"ausentes": "ausentes", "terceira_via": "terceira_via", "flavio_brando": "eleitores_flavio"}[k]].update(
{"contatos_q50": round(float(np.median(nomes[:, b]))), "convertidos_q50": round(float(np.median(conv[:, b])))})
if V_t is not None:
c["voluntarios_q10_50_90"] = [round(float(v)) for v in np.percentile(V_t[:, -1], [10, 50, 90])]
c["nomes_distintos_q50"] = round(float(np.median(nomes.sum(1)))) if nomes is not None else 0
c["trajetoria"] = {"dias": [str(d.date()) for d in DIAS],
"voluntarios": [[round(float(v)) for v in np.percentile(V_t[:, t], [10, 50, 90])] for t in range(len(DIAS))]}
if V_F is not None: c["trajetoria"]["voluntarios_flavio"] = [[round(float(v)) for v in np.percentile(V_F[:, t], [10, 50, 90])] for t in range(len(DIAS))]
if tr is not None:
P = np.column_stack([np.full(N, P_aus), np.full(N, P_tv), P_fb])
for b, k in enumerate(BOLSOES):
est = (tr[:, :, b, 2] + tr[:, :, b, 3])*P[None, :, b] # (T, N) estoque convertido (plano de voto ou confirmado)
c["trajetoria"][k] = [[round(float(v)) for v in np.percentile(est[t], [10, 50, 90])] for t in range(len(DIAS))]
c["estados_finais_q50"] = {k: [round(float(np.median(tr[-1, :, b, s])), 4) for s in range(5)] for b, k in enumerate(BOLSOES)}
# por região e por UF
dL, dF, dAbs = dest[:, :, 0], dest[:, :, 1], dest[:, :, 3]
sal = (dL - dF) - (L1 - F1)[None, :]
novos = T[:, :, 7, :3].sum(-1)
regs = sorted(set(D["regiao"]), key=lambda r_: ["NE", "SE", "S", "N", "CO", "EXT"].index(r_) if r_ in ["NE", "SE", "S", "N", "CO", "EXT"] else 9)
c["por_regiao"] = []
for r_ in regs:
m_ = np.array([x == r_ for x in D["regiao"]])
c["por_regiao"].append({"regiao": r_, "nome": REGIAO_NOME.get(r_, r_), "ausentes_1t": int(n[m_, 7].sum()), "eleitorado": int(elei[m_].sum()),
"lula_1t": int(L1[m_].sum()), "flavio_1t": int(F1[m_].sum()),
"novos_eleitores_q50": round(float(np.median(novos[:, m_].sum(1)))),
"saldo_q10": round(float(np.percentile(sal[:, m_].sum(1), 10))), "saldo_q50": round(float(np.median(sal[:, m_].sum(1)))),
"saldo_q90": round(float(np.percentile(sal[:, m_].sum(1), 90))),
"abst_1t": round(100*float(n[m_, 7].sum()/elei[m_].sum()), 2), "abst_2t_q50": round(float(np.median(100*dAbs[:, m_].sum(1)/elei[m_].sum())), 2),
"margem_1t": round(100*float((L1[m_].sum() - F1[m_].sum())/(L1[m_].sum() + F1[m_].sum())), 2),
"margem_2t_q50": round(float(np.median(100*(dL[:, m_].sum(1) - dF[:, m_].sum(1))/(dL[:, m_].sum(1) + dF[:, m_].sum(1)))), 2)})
c["por_uf"] = [{"uf": u, "nome": D["nome"][i], "regiao": D["regiao"][i], "saldo_q50": round(float(np.median(sal[:, i]))),
"margem_1t": round(100*float((L1[i] - F1[i])/(L1[i] + F1[i])), 2),
"margem_2t_q50": round(float(np.median(100*(dL[:, i] - dF[:, i])/(dL[:, i] + dF[:, i]))), 2),
"abst_1t": round(100*float(n[i, 7]/elei[i]), 2), "abst_2t_q50": round(float(np.median(100*dAbs[:, i]/elei[i])), 2)}
for i, u in enumerate(D["ufs"])]
return c, marg, dest
def matriz_mediana(T):
Mn = T.sum(axis=1)/n.sum(0)[None, :, None] # (N, 8, 4) matriz nacional agregada por sorteio
return {o: {d: [round(100*float(v), 2) for v in np.percentile(Mn[:, i, j], [10, 50, 90])] for j, d in enumerate(DEST)} for i, o in enumerate(ORIG)}
cenarios, margens, checks = [], {}, {}
mats = {}
V_sem = None
for rid, rnome in RESPOSTAS:
for cid, cnome, V0_med, I_cen in CENARIOS:
T = T_base.copy()
saldo = {k: np.zeros(N) for k in ["ausentes", "terceira_via", "brancos_nulos", "eleitores_flavio", "chamado_aberto", "resposta_flavio"]}
V0 = V0_med*np.exp(0.5*B["z_v0"])
V_t = voluntarios(V0, B["p_ac"], B["psi"]*L1[dom].sum(), ger)
tr, nomes, conv = frentes_lula(T, V_t, [B["q_aus"], B["q_tv"], B["q_fb"]], I_cen, saldo)
V_F = None
if rid == "simetrica" and V0_med > 0:
V_F = voluntarios(B["rho"]*V0, B["p_ac"], B["psi"]*F1[dom].sum(), ger)
frentes_flavio(T, V_F, [B["q_aus_F"], B["q_tv_F"], B["q_fb_F"]], I_cen, saldo)
c, marg, dest = agrega(T, cid, cnome, rid, rnome, saldo, tr, nomes, conv, V_t, V_F)
assert np.allclose(dest.sum(-1), elei[None, :], rtol=1e-9), "massa não conservada"
cenarios.append(c); margens[(cid, rid)] = marg
if rid == "nenhuma" and cid in ("sem", "forte"):
mats[cid] = matriz_mediana(T)
if cid == "forte" and rid == "nenhuma":
nomes_forte = nomes; tr_forte = tr
if cid == "sem" and rid == "nenhuma":
dest_sem = dest
del T
# referência: metas do guia cumpridas (saldo imposto sobre o cenário sem campanha; o que precisaria acontecer)
T = T_base.copy(); saldo = {k: np.zeros(N) for k in ["ausentes", "terceira_via", "brancos_nulos", "eleitores_flavio", "chamado_aberto", "resposta_flavio"]}
def _move(T, j_from, d_from, d_to, total, pesos, cap_frac=0.95):
mov = np.minimum(total*pesos[None, :], cap_frac*T[:, :, j_from, d_from]); T[:, :, j_from, d_from] -= mov; T[:, :, j_from, d_to] += mov; return mov.sum(1)
mF = sum(_move(T, ORIG.index(o), 1, 0, GUIA_METAS["terceira_via"]/2*X["perm"][o]["flavio"].mean(0).sum()/sum(X["perm"][o_]["flavio"].mean(0).sum() for o_ in TV), w_L) for o in TV)
saldo["terceira_via"] += 2*mF
saldo["brancos_nulos"] += _move(T, 6, 2, 0, GUIA_METAS["brancos_nulos"], w_L)
saldo["eleitores_flavio"] += 2*_move(T, 1, 1, 0, GUIA_METAS["eleitores_flavio"]/2, w_L)
a_l = _move(T, 7, 3, 0, 1.0e6, w_L); a_f = _move(T, 7, 3, 1, 0.5e6, w_L); saldo["ausentes"] += a_l - a_f
c_guia, marg_guia, _ = agrega(T, "guia", "Metas do guia cumpridas (referência)", "nenhuma", "Nenhuma", saldo)
del T
med_nomes = np.median(nomes_forte, axis=0) # alcance mediano do cenário forte, por bolsão
lit = {"ausentes": _p("q_aus")[0], "terceira_via": _p("q_tv")[0], "brancos_nulos": _p("q_tv")[0], "eleitores_flavio": _p("q_fb")[0]}
nec = {"ausentes": GUIA_METAS["ausentes"]/(2*_p("ν")[0] - 1)/REALIZA["ausentes"][0]/med_nomes[0],
"terceira_via": GUIA_METAS["terceira_via"]/2/REALIZA["terceira_via"][0]/med_nomes[1],
"brancos_nulos": GUIA_METAS["brancos_nulos"]/REALIZA["terceira_via"][0]/med_nomes[1],
"eleitores_flavio": GUIA_METAS["eleitores_flavio"]/2/REALIZA["flavio_brando"][0]/med_nomes[2]}
guia = {"metas": {k: int(v) for k, v in GUIA_METAS.items()}, "metas_total": int(sum(GUIA_METAS.values())),
"alcance_forte_q50": {"ausentes": int(med_nomes[0]), "terceira_via": int(med_nomes[1]), "eleitores_flavio": int(med_nomes[2])},
"taxa_necessaria": {k: {"literatura": round(lit[k], 3), "necessaria": round(float(nec[k]), 3), "razao": round(float(nec[k]/lit[k]), 1)} for k in nec},
"margem_se_cumprido": c_guia["margem_q"]["50"], "p_lula_se_cumprido": c_guia["p_lula_so_migracao"],
"p_lula_se_cumprido_com_deriva": c_guia["p_lula_com_deriva"]}
# ---- verificações
br = D["br"]
checks["br_ok"] = bool(all(abs(float(br[c]) - float(D["tab"][c].sum())) < 1 for c in ["eleitorado", "comparecimento", "abstencao", "votos_validos", "brancos", "nulos", "lula", "flavio", "cury", "renan", "caiado", "zema", "outros"]))
checks["massa_ok"] = bool(np.allclose(dest_sem.sum(-1), elei[None, :], rtol=1e-9))
checks["linhas_ok"] = bool(np.allclose(M.sum(-1), 1.0, atol=1e-9))
fr = tr_forte
checks["cadeia_ok"] = bool(np.all(fr >= -1e-12) and np.allclose(fr.sum(-1), 1.0, atol=1e-9)
and np.all((fr[-1, :, :, 2] + fr[-1, :, :, 3])*1.0 <= (1 - fr[-1, :, :, 0]) + 1e-9))
esp = (comparacao or {}).get("estrutural_base_margem")
rec = estrutural_check(D, transf)
checks["estrutural_ok"] = bool(esp is None or abs(rec - esp) <= 0.3)
ordem = [margens[(c, "nenhuma")] for c, *_ in CENARIOS]
med = [float(np.median(m)) for m in ordem]; pl = [float((m > 0).mean()) for m in ordem]
checks["monotonico_ok"] = bool(all(med[i] <= med[i + 1] + 1e-9 for i in range(3)) and all(pl[i] <= pl[i + 1] + 1e-9 for i in range(3))
and all(float(np.median(margens[(c, "simetrica")])) <= float(np.median(margens[(c, "nenhuma")])) + 1e-9 for c, *_ in CENARIOS))
seg = round(time.time() - t0, 1)
checks["tempo_ok"] = bool(seg < 60)
return {"versao": 1, "usado": True, "seed": seed, "n_sorteios": N, "segundos": seg, "hoje": str(hoje.date()),
"inicio_cadeia": str(inicio.date()), "dias": [str(d.date()) for d in DIAS],
"fases": [{"nome": a, "inicio": b, "fim": c_, "mult_conversao": m, "confirmacao_dia": cf, "encerramento_dia": e} for a, b, c_, m, cf, e, g in FASES],
"transf_fonte": transf_fonte, "transf": {k: [round(v[0], 3), round(v[1], 3)] for k, v in transf.items()},
"estoques_1t": {"lula": int(n[:, 0].sum()), "flavio": int(n[:, 1].sum()), "terceira_via": int(n[:, 2:6].sum()), "sem_2a_opcao": int(round(sem_2a)),
"brancos_nulos": int(n[:, 6].sum()), "abstencao": int(n[:, 7].sum()), "eleitorado": int(elei.sum()), "comparecimento": int(D["comparecimento"].sum()),
"margem_1t_votos": int(n[:, 0].sum() - n[:, 1].sum()), "margem_1t": round(100*float((n[:, 0].sum() - n[:, 1].sum())/(n[:, 0].sum() + n[:, 1].sum())), 2),
"por_regiao": [{"regiao": r_, "nome": REGIAO_NOME.get(r_, r_), "ausentes": int(n[[x == r_ for x in D["regiao"]], 7].sum()),
"terceira_via": int(n[[x == r_ for x in D["regiao"]], 2:6].sum()), "brancos_nulos": int(n[[x == r_ for x in D["regiao"]], 6].sum())}
for r_ in ["NE", "SE", "S", "N", "CO", "EXT"] if r_ in D["regiao"]]},
"bolsoes": {"ausentes": int(P_aus), "terceira_via_bn": int(P_tv), "flavio_brando_q50": int(np.median(P_fb))},
"priors": priors(), "cenarios": cenarios + [c_guia], "guia": guia, "matriz_mediana": mats, "hist_bins": [float(b) for b in BINS],
"comparacao": {**(comparacao or {}), "sd_deriva": round(float(sd_deriva), 2), "margem_sem_campanha_q50": cenarios[0]["margem_q"]["50"],
"p_lula_sem_campanha_com_deriva": cenarios[0]["p_lula_com_deriva"],
"check_estrutural": {"recalculado": round(float(rec), 2), "esperado": esp, "dif": (None if esp is None else round(float(rec - esp), 2))}},
"abstencao_bruta": {"r_out_q10_50_90": _q(B["r_out"], [10, 50, 90]), "r_in_q10_50_90": _q(X["r_in"].mean(1), [10, 50, 90]),
"saem_mi_q50": round(float(np.median(B["r_out"]*D["comparecimento"].sum()))/1e6, 2),
"entram_mi_q50": round(float(np.median((X["r_in"]*n[None, :, 7]).sum(1)))/1e6, 2),
"tau_q10_50_90": _q(B["tau"], [10, 50, 90]), "delta_nac_q10_50_90": _q(B["delta_nac"], [10, 50, 90])},
"checks": checks}
# ------------------------------------------------------------------ HTML
def f(v, d=1, sinal=False):
v = round(float(v), d) + 0.0
s = f"{abs(v) if sinal else v:.{d}f}".replace(".", ",")
if sinal: return ("+" if v > 0 else "−" if v < 0 else "") + s
return s.replace("-", "−")
def fs(v, d=1): return f(v, d, True)
def pct(p, d=0): return f(100*p, d) + "%"
def mil(n): return f"{int(round(n)):,}".replace(",", ".")
def mi(v, d=1, sinal=False):
"""Votos em milhões (ou mil, abaixo de 1 mi)."""
v = float(v)
if abs(v) >= 950_000: return f(v/1e6, d, sinal) + " mi"
return f(v/1e3, 0, sinal) + " mil"
def _cel(R, cid, rid):
for c in R["cenarios"]:
if c["id"] == cid and c["resposta"] == rid: return c
raise KeyError((cid, rid))
def svg_trajetoria(R, c):
W, Hh = 900, 300; x0, x1, y0, y1 = 70, 880, 44, 236
dias = R["dias"]; T = len(dias)
series = [("ausentes", "Ausentes trazidos às urnas", "var(--accent)"), ("terceira_via", "3ª via e brancos/nulos", "var(--amber)"), ("flavio_brando", "Eleitores brandos de Flávio", "var(--flavio)")]
vmax = max(max(v[2] for v in c["trajetoria"][k]) for k, _, _ in series)*1.08 or 1
X = lambda t: x0 + t/(T - 1)*(x1 - x0)
Y = lambda v: y1 - v/vmax*(y1 - y0)
s = [f'<svg viewBox="0 0 {W} {Hh}" role="img" aria-label="Trajetória diária do estoque de eleitores convertidos por bolsão, de 5 a 24 de outubro, no cenário forte">']
for fase in R["fases"]:
a = dias.index(fase["inicio"]); b = dias.index(fase["fim"])
op = 0.0 if fase["mult_conversao"] == 0 and fase["confirmacao_dia"] == 0 and fase["nome"] != "Largada" else min(0.05 + 0.08*fase["mult_conversao"], 0.18)
if fase["nome"].startswith("Pausa"): op = 0.0
s.append(f'<rect x="{X(a) - (x1 - x0)/(T - 1)/2:.1f}" y="{y0}" width="{(b - a + 1)*(x1 - x0)/(T - 1):.1f}" height="{y1 - y0}" fill="var(--accent)" opacity="{op:.2f}"/>')
s.append(f'<text class="axis-label" x="{(X(a) + X(b))/2:.1f}" y="{y0 - 8}" text-anchor="middle">{H.escape(fase["nome"])}</text>')
passo = 10**math.floor(math.log10(vmax)) if vmax > 0 else 1
if vmax/passo < 3: passo /= 2
v = 0
while v <= vmax:
s.append(f'<line class="grid-line" x1="{x0}" y1="{Y(v):.1f}" x2="{x1}" y2="{Y(v):.1f}"/>')
s.append(f'<text class="axis-label" x="{x0 - 8}" y="{Y(v) + 4:.1f}" text-anchor="end">{mi(v, 1) if v else "0"}</text>')
v += passo
for k, rot, cor in series:
tr = c["trajetoria"][k]
band = " ".join(f"{X(t):.1f},{Y(tr[t][2]):.1f}" for t in range(T)) + " " + " ".join(f"{X(t):.1f},{Y(tr[t][0]):.1f}" for t in range(T - 1, -1, -1))
s.append(f'<polygon points="{band}" fill="{cor}" opacity="0.14"/>')
s.append(f'<polyline class="series-line" points="{" ".join(f"{X(t):.1f},{Y(tr[t][1]):.1f}" for t in range(T))}" stroke="{cor}"/>')
s.append(f'<text class="dot-label" x="{x1 + 4}" y="{Y(tr[-1][1]) + 4:.1f}" fill="{cor}" font-size="10">{mi(tr[-1][1])}</text>')
if R["hoje"] in dias:
th = dias.index(R["hoje"])
s.append(f'<line x1="{X(th):.1f}" y1="{y0}" x2="{X(th):.1f}" y2="{y1}" stroke="var(--ink)" stroke-width="1.2" stroke-dasharray="4,3"/>')
s.append(f'<text class="axis-label" x="{X(th) + 4:.1f}" y="{y1 - 6}" fill="var(--ink)">hoje</text>')
for t in range(0, T, 2):
s.append(f'<text class="axis-label" x="{X(t):.1f}" y="{y1 + 16}" text-anchor="middle">{dias[t][8:10]}/{dias[t][5:7]}</text>')
s.append(f'<text class="axis-label" x="{(x0 + x1)/2:.0f}" y="{y1 + 34}" text-anchor="middle">eleitores com plano de voto ou confirmados, por bolsão (mediana e faixa de 80%); fundo mais escuro = fase de conversa mais intensa</text>')
lx = x0
for k, rot, cor in series:
s.append(f'<rect x="{lx}" y="{Hh - 12}" width="14" height="4" fill="{cor}"/><text class="dot-label" x="{lx + 18}" y="{Hh - 7}" fill="var(--ink-soft)" font-weight="400">{rot}</text>')
lx += 230
s.append("</svg>")
return "\n".join(s)
def svg_margem(R):
W, Hh = 900, 330; x0, x1, y0, y1 = 60, 880, 106, 276
bins = np.array(R["hist_bins"])
cells = [(c, cor) for c, cor in zip([_cel(R, cid, "nenhuma") for cid, *_ in CENARIOS] + [_cel(R, "guia", "nenhuma")], ["var(--ink-3)", "var(--accent-med)", "var(--accent)", "var(--amber)", "var(--lula)"])]
hmax = max(max(c["hist_margem"]) for c, _ in cells) or 1
X = lambda v: x0 + (v - bins[0])/(bins[-1] - bins[0])*(x1 - x0)
Y = lambda d: y1 - d/hmax*(y1 - y0)
s = [f'<svg viewBox="0 0 {W} {Hh}" role="img" aria-label="Distribuição da margem Lula − Flávio no 2º turno por cenário de campanha, só pela migração">']
h0 = cells[0][0]["hist_margem"]
bw = (x1 - x0)/len(h0)
for i, d in enumerate(h0):
s.append(f'<rect x="{X(bins[i]) + 0.5:.1f}" y="{Y(d):.1f}" width="{max(bw - 1, 0.5):.1f}" height="{y1 - Y(d):.1f}" fill="var(--ink-3)" opacity="0.28"><title>margem {f(bins[i], 2, True)} a {f(bins[i + 1], 2, True)} p.p. · sem campanha dirigida: {d*(bins[1] - bins[0])*100:.1f}% dos sorteios</title></rect>')
for c, cor in cells[1:]:
pts = []
for i, d in enumerate(c["hist_margem"]):
pts += [f"{X(bins[i]):.1f},{Y(d):.1f}", f"{X(bins[i + 1]):.1f},{Y(d):.1f}"]
s.append(f'<polyline points="{" ".join(pts)}" fill="none" stroke="{cor}" stroke-width="2.2" stroke-linejoin="round"{" stroke-dasharray=\"5,3\"" if c["id"] == "guia" else ""}/>')
s.append(f'<line x1="{X(0):.1f}" y1="{y0 - 6}" x2="{X(0):.1f}" y2="{y1}" stroke="var(--ink)" stroke-width="1.2"/>')
mp = R["comparacao"].get("mediana_principal")
if mp is not None:
s.append(f'<line x1="{X(mp):.1f}" y1="{y0 - 6}" x2="{X(mp):.1f}" y2="{y1}" stroke="var(--flavio)" stroke-width="1.4" stroke-dasharray="4,3"/>')
s.append(f'<text class="axis-label" x="{X(mp) + 4:.1f}" y="{y0 + 4}" fill="var(--flavio)">mediana do modelo principal {fs(mp)}</text>')
for v in range(int(bins[0]), int(bins[-1]) + 1):
s.append(f'<text class="axis-label" x="{X(v):.1f}" y="{y1 + 18}" text-anchor="middle">{"+" if v > 0 else ""}{v}</text>')
s.append(f'<text class="axis-label" x="{(x0 + x1)/2:.0f}" y="{y1 + 36}" text-anchor="middle">margem Lula − Flávio em 25/out só pela migração, sem a deriva das pesquisas (p.p. de votos válidos; caudas além do eixo somadas nas pontas)</text>')
for k, (c, cor) in enumerate(cells):
xx, yy = x0, 14 + k*15
if k == 0: s.append(f'<rect x="{xx}" y="{yy - 8}" width="14" height="9" fill="var(--ink-3)" opacity="0.28"/>')
else: s.append(f'<line x1="{xx}" y1="{yy - 4}" x2="{xx + 14}" y2="{yy - 4}" stroke="{cor}" stroke-width="2.2"{" stroke-dasharray=\"5,3\"" if c["id"] == "guia" else ""}/>')
s.append(f'<text class="dot-label" x="{xx + 20}" y="{yy}" fill="var(--ink-soft)" font-weight="400">{H.escape(c["nome"])}: mediana {fs(c["margem_q"]["50"], 2)} (80% de {fs(c["margem_q"]["10"])} a {fs(c["margem_q"]["90"])}) · Lula {pct(c["p_lula_so_migracao"], 1)} só migração · {pct(c["p_lula_com_deriva"], 1)} com deriva</text>')
s.append("</svg>")
return "\n".join(s)
def _q3(d, k="q50", lo="q10", hi="q90", fn=mi):
return f'{fn(d[k], 2, True)} <span class="muted">({fn(d[lo], 1, True)} a {fn(d[hi], 1, True)})</span>'
def build(R, DIR=None):
"""Seção #migracao a partir de R["migracao"] (resultados.json). Vazio se a análise não rodou."""
Mg = R.get("migracao") or {}
if not Mg.get("usado"): return ""
E, G, C = Mg["estoques_1t"], Mg["guia"], Mg["comparacao"]
sem, mod, forte, maxi = (_cel(Mg, k, "nenhuma") for k in ("sem", "moderada", "forte", "maxima"))
forte_s = _cel(Mg, "forte", "simetrica"); guia = _cel(Mg, "guia", "nenhuma")
sf = forte["saldo_frentes"]
p_prin = C.get("p_lula_principal"); p_mc = C.get("p_lula_mcmc")
css = ("<style>"
"#migracao .mg-tab th,#migracao .mg-tab td{white-space:nowrap}"
"#migracao .mg-tab td.txt{white-space:normal;min-width:180px}"
"#migracao .mg-tab tr.ref td{background:var(--surface-2)}"
"#migracao .mg-tab tr.ref td.txt{font-weight:600}"
"#migracao .mg-mat td.lula{color:var(--lula);font-weight:600}#migracao .mg-mat td.flavio{color:var(--flavio);font-weight:600}"
"#migracao .nota{font-size:12.5px;color:var(--ink-soft);margin:8px 0 0;font-family:var(--sans);line-height:1.55}"
"#migracao h3.sub{font-size:19px;margin:30px 0 6px}"
"#migracao .estados{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 0;font:12.5px var(--sans);color:var(--ink-soft)}"
"#migracao .estados span{border:1px solid var(--line);border-radius:999px;padding:3px 10px;background:var(--surface)}"
"@media(max-width:680px){#migracao .mg-tab td.txt{min-width:140px}}"
"</style>")
def tile(eb, val, cap):
return f'<div class="tile"><span class="eyebrow">{eb}</span><div class="val" style="font-size:22px;line-height:1.3">{val}</div><div class="cap">{cap}</div></div>'
sal_tot = sf["total"]
tiles = ('<div class="tiles">'
+ tile("Saldo da campanha forte", f'{mi(sal_tot["q50"], 2, True)} <small>votos</small>',
f'80% entre {mi(sal_tot["q10"], 1, True)} e {mi(sal_tot["q90"], 1, True)}; 500 mil voluntários iniciais, {mi(forte["voluntarios_q10_50_90"][1])} ao fim. O guia pede +{mi(G["metas_total"], 1)}.')
+ tile("Chance de Lula: sem → forte", f'{pct(sem["p_lula_com_deriva"], 1)} → {pct(forte["p_lula_com_deriva"], 1)}',
f'com a deriva das pesquisas, como no modelo principal ({pct(p_prin, 1) if p_prin is not None else "—"}; MCMC {pct(p_mc, 1) if p_mc is not None else "—"}). Só pela migração, sem deriva: {pct(sem["p_lula_so_migracao"], 1)} → {pct(forte["p_lula_so_migracao"], 1)}.')
+ tile("Abstenção esperada no 2º turno", f'{f(sem["abstencao_q10_50_90"][1])}%',
f'80% entre {f(sem["abstencao_q10_50_90"][0])}% e {f(sem["abstencao_q10_50_90"][2])}%; foi {f(100*E["abstencao"]/E["eleitorado"])}% no 1º turno. Saem {f(Mg["abstencao_bruta"]["saem_mi_q50"])} mi, entram {f(Mg["abstencao_bruta"]["entram_mi_q50"])} mi.')
+ tile("Conversão necessária × literatura", f'{f(G["taxa_necessaria"]["terceira_via"]["razao"], 0)}× <small>na 3ª via</small>',
f'para cumprir a meta do guia com o alcance forte, 1 em {f(1/G["taxa_necessaria"]["terceira_via"]["necessaria"], 1) if G["taxa_necessaria"]["terceira_via"]["necessaria"] < 1 else "menos de 1"} contato teria de virar voto; a literatura dá 1 em {f(1/G["taxa_necessaria"]["terceira_via"]["literatura"], 0)}.')
+ '</div>')
# tabela 1: cenários
rows1 = []
for c in Mg["cenarios"]:
s_ = c["saldo_frentes"]; ref = c["id"] == "guia"
vol = ("—" if ref else "0" if c["voluntarios_q10_50_90"][1] == 0 else f'{mi(c["voluntarios_q10_50_90"][1])} <span class="muted">({mi(c["voluntarios_q10_50_90"][0])}–{mi(c["voluntarios_q10_50_90"][2])})</span>')
nomes = "—" if ref else "0" if c["nomes_distintos_q50"] == 0 else mi(c["nomes_distintos_q50"])
rows1.append(f'<tr{" class=ref" if ref else ""}><td class="txt">{H.escape(c["nome"])}</td><td>{H.escape(c["resposta_nome"])}</td><td class="num">{vol}</td><td class="num">{nomes}</td>'
+ "".join(f'<td class="num">{mi(s_[k]["q50"], 2, True) if s_[k]["q50"] else "0"}</td>' for k in ["ausentes", "terceira_via", "brancos_nulos", "eleitores_flavio", "chamado_aberto", "resposta_flavio"])
+ f'<td class="num"><b>{mi(s_["total"]["q50"], 2, True)}</b> <span class="muted">({mi(s_["total"]["q10"], 1, True)} a {mi(s_["total"]["q90"], 1, True)})</span></td>'
+ f'<td class="pnum {"lead" if c["margem_q"]["50"] > 0 else "trail"}">{fs(c["margem_q"]["50"])} <span class="muted">({fs(c["margem_q"]["10"])} a {fs(c["margem_q"]["90"])})</span></td>'
+ f'<td class="pnum">{pct(c["p_lula_so_migracao"], 1)}</td><td class="pnum">{pct(c["p_lula_com_deriva"], 1)}</td></tr>')
# tabela 2: frente a frente com o guia
ROT_FRENTE = {"terceira_via": ("Terceira via", "dividir meio a meio os votos de Cury, Caiado e Renan"), "eleitores_flavio": ("Votos de Flávio", "trazer 600 mil eleitores pouco convictos"),
"brancos_nulos": ("Brancos e nulos", "convencer 1 milhão a votar em Lula"), "ausentes": ("Quem não foi votar", "levar às urnas 1,5 milhão de ausentes em redutos de Lula")}
rows2 = []
for k in ["terceira_via", "eleitores_flavio", "brancos_nulos", "ausentes"]:
tn = G["taxa_necessaria"][k]; alc = G["alcance_forte_q50"]["terceira_via" if k == "brancos_nulos" else k]
rows2.append(f'<tr><td class="txt"><b>{ROT_FRENTE[k][0]}</b><br><span class="muted">{ROT_FRENTE[k][1]}</span></td><td class="num">{mi(G["metas"][k], 1, True)}</td>'
f'<td class="num">{mi(sf[k]["q50"], 2, True)} <span class="muted">({mi(sf[k]["q10"], 2, True)} a {mi(sf[k]["q90"], 2, True)})</span></td>'
f'<td class="num">{mi(alc)}</td><td class="num">{pct(tn["literatura"], 0)}</td><td class="num">{pct(tn["necessaria"], 0) if tn["necessaria"] < 1 else "acima de 100%"}</td><td class="num"><b>{f(tn["razao"], 0)}×</b></td></tr>')
rows2.append(f'<tr class="ref"><td class="txt">Soma</td><td class="num">{mi(G["metas_total"], 1, True)}</td><td class="num">{mi(sal_tot["q50"], 2, True)} <span class="muted">({mi(sal_tot["q10"], 1, True)} a {mi(sal_tot["q90"], 1, True)})</span></td><td class="num">{mi(forte["nomes_distintos_q50"])}</td><td></td><td></td><td></td></tr>')
# tabela 3: matriz mediana
mat_s, mat_f = Mg["matriz_mediana"]["sem"], Mg["matriz_mediana"]["forte"]
rows3 = []
for o in ORIG:
rows3.append(f'<tr><td class="txt">{ROT_ORIG[o]}<br><span class="muted">{mi(E[{"lula": "lula", "flavio": "flavio", "bn": "brancos_nulos", "abs": "abstencao"}[o]] if o in ("lula", "flavio", "bn", "abs") else 0) if o in ("lula", "flavio", "bn", "abs") else ""}</span></td>'
+ "".join(f'<td class="num {d if d in ("lula", "flavio") else ""}">{f(mat_s[o][d][1])}% <span class="muted">({f(mat_s[o][d][0])}–{f(mat_s[o][d][2])})</span></td>' for d in DEST)
+ "".join(f'<td class="num">{fs(mat_f[o][d][1] - mat_s[o][d][1], 2)}</td>' for d in DEST) + "</tr>")
# tabela 4: por região (cenário forte, sem resposta)
rows4 = []
for r_, r1 in zip(forte["por_regiao"], sem["por_regiao"]):
rows4.append(f'<tr><td class="txt">{H.escape(r_["nome"])}</td><td class="num">{mi(r_["ausentes_1t"], 2)}</td><td class="num">{f(r_["abst_1t"])}%</td><td class="num">{f(r1["abst_2t_q50"])}%</td>'
f'<td class="num">{mi(r1["novos_eleitores_q50"], 2)}</td><td class="num">{mi(r1["saldo_q50"], 2, True)}</td><td class="num">{mi(r_["novos_eleitores_q50"], 2)}</td>'
f'<td class="num">{mi(r_["saldo_q50"], 2, True)} <span class="muted">({mi(r_["saldo_q10"], 2, True)} a {mi(r_["saldo_q90"], 2, True)})</span></td>'
f'<td class="pnum {"lead" if r_["margem_1t"] > 0 else "trail"}">{fs(r_["margem_1t"])}</td><td class="pnum {"lead" if r_["margem_2t_q50"] > 0 else "trail"}">{fs(r_["margem_2t_q50"])}</td></tr>')
priors_rows = "".join(f'<tr><td class="txt">{H.escape(p["nome"])}</td><td class="num">{H.escape(p["simbolo"])}</td><td>{H.escape(p["dist"])}</td><td class="num">{"cenário" if p["media"] is None else (f(p["media"], 3) if p["media"] < 0.1 else f(p["media"], 2))}</td><td class="num">{f(p["sd"], 3) if p["sd"] < 0.1 else f(p["sd"], 2)}</td><td class="txt muted">{H.escape(p["fonte"])}</td></tr>' for p in Mg["priors"])
ab = Mg["abstencao_bruta"]
ch = Mg["checks"]
dif_forte = forte["margem_q"]["50"] - sem["margem_q"]["50"]
dif_max = maxi["margem_q"]["50"] - sem["margem_q"]["50"]
tldr = (f'Sem campanha dirigida, a migração leva a margem para {fs(sem["margem_q"]["50"])} ponto{"s" if abs(sem["margem_q"]["50"]) >= 2 else ""} (80% entre {fs(sem["margem_q"]["10"])} e {fs(sem["margem_q"]["90"])}). '
f'Uma campanha forte de conversa pessoal, com 500 mil voluntários iniciais e a eficácia que a literatura mede, rende {mi(sal_tot["q50"], 1, True)} votos de saldo '
f'e move a margem em {fs(dif_forte)} ponto{"s" if abs(dif_forte) >= 2 else ""}; a máxima, {fs(dif_max)}. A virada exigiria {mi(G["metas_total"], 1, True)}: '
f'com o alcance forte, a conversão por contato teria de ser {f(G["taxa_necessaria"]["terceira_via"]["razao"], 0)} vezes a da literatura na terceira via. '
f'A chance de Lula, com a deriva das pesquisas, vai de {pct(sem["p_lula_com_deriva"], 1)} sem campanha a {pct(forte["p_lula_com_deriva"], 1)} com a forte. A manchete não muda: isto é análise condicional, não previsão.')
hoje_txt = f'{Mg["hoje"][8:10]}/{Mg["hoje"][5:7]}'
return f"""
<section id="migracao">
{css}
<div class="kicker">Migração</div>
<h2>Para onde vão os {f(E["abstencao"]/1e6, 0)} milhões que faltaram, os {f(E["terceira_via"]/1e6, 0)} milhões da terceira via e os {f(E["brancos_nulos"]/1e6, 0)} milhões de brancos e nulos</h2>
<p class="tldr"><b>Em uma frase:</b> {tldr}</p>
<div class="howto"><b>Como ler.</b> O resultado oficial por UF é o estado inicial: cada eleitor está numa de oito caixas (votou em Lula, em Flávio, em cada um dos candidatos da 3ª via, branco/nulo ou não votou). Uma [[markov|cadeia de Markov]] leva cada caixa aos quatro destinos do 2º turno (Lula, Flávio, branco/nulo, não vota) com uma matriz de transição sorteada: cada linha é uma [[dirichlet|Dirichlet]] em torno da 2ª opção declarada (Quaest, 3–6/set) e dos fluxos de comparecimento. Por cima, uma segunda cadeia, diária, acompanha a campanha de conversa pessoal do guia "Três nomes, três semanas" (5 a 24/out): quem é alcançado, quem faz um plano de voto, quem confirma, quem desiste. <b>"Sem campanha dirigida"</b> não é "sem campanha": a 2ª opção e os fluxos de 2022 já contêm a campanha normal dos dois lados; o que a escada mede é o acréscimo de uma mobilização organizada junto a esses três grupos. {Mg["n_sorteios"]//1000} mil sorteios por célula, os mesmos números aleatórios em todas (comparação pareada).</div>
{tiles}
<h3 class="sub">1. A campanha dia a dia: quantos eleitores a conversa alcança e converte</h3>
<div class="howto"><b>Como ler.</b> Cenário forte, sem resposta de Flávio: {mi(forte["voluntarios_q10_50_90"][1])} voluntários ao fim (80% entre {mi(forte["voluntarios_q10_50_90"][0])} e {mi(forte["voluntarios_q10_50_90"][2])}), cada um com três nomes (um ausente, um da 3ª via ou branco/nulo, um indeciso). A linha é o estoque de eleitores com plano de voto ou confirmados; a faixa, 80% dos sorteios. As fases seguem o calendário do guia: largada (5–6), 1ª conversa (7–11), pausa de Aparecida (12), 2ª conversa (13–18), fechamento (19–22), confirmação (23–24). A cadeia começa em {Mg["inicio_cadeia"][8:10]}/{Mg["inicio_cadeia"][5:7]}; hoje é {hoje_txt}.</div>
<div class="trend-card">{svg_trajetoria(Mg, forte)}</div>
<div class="estados"><span>S0 · não alcançado</span><span>S1 · alcançado</span><span>S2 · plano de voto</span><span>S3 · confirmado</span><span>S4 · recusou ou foi deixado</span></div>
<p class="nota">Ao fim da campanha forte, nos ausentes: {pct(forte["estados_finais_q50"]["ausentes"][1] + forte["estados_finais_q50"]["ausentes"][2] + forte["estados_finais_q50"]["ausentes"][3] + forte["estados_finais_q50"]["ausentes"][4], 1)} do bolsão de {mi(Mg["bolsoes"]["ausentes"])} alcançados, {pct(forte["estados_finais_q50"]["ausentes"][2] + forte["estados_finais_q50"]["ausentes"][3], 2)} com plano de voto ou confirmados. Os contatos são poucos diante do bolsão: o gargalo é o alcance, não a conversa.</p>
<h3 class="sub">2. A margem do 2º turno em cada cenário</h3>
<div class="howto"><b>Como ler.</b> Distribuição da margem Lula − Flávio só pela migração (resultado oficial + matriz de transição + campanha), sem a deriva das pesquisas: é o que o voto contado e a 2ª opção declarada permitem dizer. Barras: sem campanha dirigida. Linhas: moderada (100 mil voluntários iniciais), forte (500 mil) e máxima (2 milhões, teto de {pct(0.05)} dos votos de Lula). A linha tracejada é a mediana do modelo principal, que parte das pesquisas e do voto contado e inclui a deriva até 25/out.</div>
<div class="trend-card">{svg_margem(Mg)}</div>
<div class="table-card" style="margin-top:14px"><table class="mg-tab" style="min-width:1100px">
<thead><tr><th>Cenário</th><th>Resposta de Flávio</th><th>Voluntários ao fim</th><th>Nomes alcançados</th><th>Ausentes</th><th>3ª via</th><th>Brancos/nulos</th><th>Eleitores de Flávio</th><th>Chamado aberto</th><th>Resposta de Flávio</th><th>Saldo total <span class="muted">(80%)</span></th><th>Margem <span class="muted">(80%)</span></th><th>Lula só migração</th><th>Lula com deriva</th></tr></thead>
<tbody>{"".join(rows1)}</tbody></table></div>
<p class="nota">Saldo em votos (Lula − Flávio), mediana dos sorteios; a soma das frentes pode diferir do total por arredondamento e pelos tetos de massa disponível por UF. "Com deriva" soma à margem da migração a mesma deriva t<sub>5</sub> do modelo principal (σ = {f(C["sd_deriva"])} p.p. até 25/out): é a chance comparável à manchete ({pct(p_prin) if p_prin is not None else "—"} principal, {pct(p_mc) if p_mc is not None else "—"} MCMC), mas sem as pesquisas. A última linha impõe as metas do guia ({mi(G["metas_total"], 1, True)}) sobre o cenário sem campanha: mostra onde a margem iria se elas fossem cumpridas, não quanto é provável cumpri-las.</p>
<h3 class="sub">3. Frente a frente com o guia: o que precisaria acontecer</h3>
<div class="howto"><b>Como ler.</b> O guia soma +{mi(G["metas_total"], 1)} em quatro frentes. Para cada uma, o saldo que o modelo espera no cenário forte, o nº de nomes que a campanha forte alcança nesse bolsão e a conversão por contato que seria necessária para cumprir a meta com esse alcance, comparada com a que a literatura de mobilização mede (Gerber & Green; Nickerson & Rogers; Kalla & Broockman).</div>
<div class="table-card" style="margin-top:14px"><table class="mg-tab" style="min-width:820px">
<thead><tr><th>Frente</th><th>Meta do guia</th><th>Modelo, campanha forte <span class="muted">(80%)</span></th><th>Nomes alcançados</th><th>Conversão na literatura</th><th>Conversão necessária</th><th>Razão</th></tr></thead>
<tbody>{"".join(rows2)}</tbody></table></div>
<p class="nota">Se as quatro metas fossem cumpridas, a margem mediana iria a {fs(G["margem_se_cumprido"])} ponto e a chance de Lula, só pela migração, a {pct(G["p_lula_se_cumprido"], 1)} ({pct(G["p_lula_se_cumprido_com_deriva"], 1)} com deriva). Cada eleitor da 3ª via ou de Flávio que muda vale dois votos no saldo; cada branco/nulo ou ausente trazido, um (os ausentes trazidos nominalmente votam {pct(0.8)} em Lula, por isso rendem 0,6 cada). Brancos e nulos dividem com a 3ª via o mesmo bolsão de nomes.</p>
<h3 class="sub">4. A matriz de transição: de onde vem e para onde vai cada voto</h3>
<div class="howto"><b>Como ler.</b> Parcela de cada origem que termina em cada destino no 2º turno (mediana e faixa de 80% dos sorteios), no cenário sem campanha dirigida; à direita, quanto a campanha forte muda cada célula, em pontos. A 2ª opção declarada é o centro das linhas da 3ª via; quem não declarou 2ª opção decide com probabilidade d<sub>3</sub> e se divide s<sub>3</sub>; os brancos e nulos decidem com d<sub>bn</sub>. Quem votou e falta (r<sub>out</sub>) é compensado por quem faltou e vota (r<sub>in</sub>), com a variação líquida da abstenção sorteada em torno do padrão de 2022 e perfil mais pró-Lula nos dois sentidos (τ).</div>
<div class="table-card" style="margin-top:14px"><table class="mg-tab mg-mat" style="min-width:900px">
<thead><tr><th rowspan="2">Origem (1º turno)</th><th colspan="4">Destino no 2º turno, sem campanha dirigida</th><th colspan="4">Campanha forte: diferença (p.p.)</th></tr>
<tr><th>Lula</th><th>Flávio</th><th>Branco/nulo</th><th>Não vota</th><th>Lula</th><th>Flávio</th><th>Branco/nulo</th><th>Não vota</th></tr></thead>
<tbody>{"".join(rows3)}</tbody></table></div>
<p class="nota">Fluxos brutos de comparecimento: r<sub>out</sub> mediano {pct(ab["r_out_q10_50_90"][1])} dos que votaram ({f(ab["saem_mi_q50"])} mi), r<sub>in</sub> mediano {pct(ab["r_in_q10_50_90"][1])} dos ausentes ({f(ab["entram_mi_q50"])} mi); variação líquida nacional da abstenção {fs(ab["delta_nac_q10_50_90"][1])} p.p. (80% entre {fs(ab["delta_nac_q10_50_90"][0])} e {fs(ab["delta_nac_q10_50_90"][2])}); inclinação de perfil τ mediana {f(ab["tau_q10_50_90"][1], 0)} p.p. de margem. Com entrada e saída iguais e a mesma inclinação nos dois sentidos, o efeito líquido do comparecimento é próximo de zero; só o desvio em relação a 2022 importa, como na seção Abstenção.</p>
<h3 class="sub">5. Por região: onde a campanha rende e onde sai pela culatra</h3>
<div class="howto"><b>Como ler.</b> Ausentes do 1º turno, abstenção esperada no 2º, novos eleitores (quem não votou em 4/out e vota em 25/out) e saldo de votos (variação de Lula − Flávio em relação ao 1º turno) sem campanha e com a campanha forte. O chamado aberto a votar só entra com força nas UFs em que Lula liderou; nas de Flávio, o modelo supõe que um apelo genérico traz 30% do efeito, com a composição local, que favorece Flávio, como o guia avisa.</div>
<div class="table-card" style="margin-top:14px"><table class="mg-tab" style="min-width:980px">
<thead><tr><th rowspan="2">Região</th><th rowspan="2">Ausentes 1º turno</th><th rowspan="2">Abstenção 1º turno</th><th rowspan="2">Abstenção 2º turno esperada</th><th colspan="2">Sem campanha dirigida</th><th colspan="2">Campanha forte</th><th rowspan="2">Margem 1º turno</th><th rowspan="2">Margem 2º turno (forte)</th></tr>
<tr><th>Novos eleitores</th><th>Saldo</th><th>Novos eleitores</th><th>Saldo <span class="muted">(80%)</span></th></tr></thead>
<tbody>{"".join(rows4)}</tbody></table></div>
<p class="nota">Saldo em votos e margem em p.p. de válidos, medianas. O exterior entra na massa e na margem, mas fica fora da campanha e da inclinação de perfil.</p>
<div class="variables" style="margin-top:18px">
<div class="variable"><h4>Como a cadeia funciona</h4><p>Duas cadeias encaixadas. A primeira é de um passo: cada eleitor do 1º turno, classificado por UF e pelo que fez em 4/out, vai para um dos quatro destinos do 2º turno segundo uma matriz 8 × 4 sorteada; a massa é conservada (a soma dos destinos é o eleitorado de cada UF). A segunda é diária e não homogênea: em cada bolsão (ausentes, 3ª via e brancos/nulos, eleitores brandos de Flávio), um eleitor passa de não alcançado a alcançado quando entra na lista de um voluntário; de alcançado a convertido com probabilidade proporcional à conversão por contato q, distribuída pelas fases do guia (a 2ª conversa pesa 1,4; o fechamento e a 1ª, 0,8; a pausa, 0); de convertido a confirmado no fechamento e na confirmação; e volta a alcançado com δ<sub>rev</sub> por dia. Quem confirma vota como combinado em {pct(0.85)} (ausentes) ou {pct(0.9)} dos casos; quem só tem plano de voto, em {pct(0.55)}. O estoque convertido é repartido pelas UFs em proporção ao voto de Lula e aplicado sobre os destinos da primeira cadeia, com teto na massa disponível em cada caixa.</p></div>
<div class="variable"><h4>Resposta de Flávio e chamado aberto</h4><p>Na coluna "simétrica", Flávio monta uma rede ρ vezes a do PT (mediana {f(0.6, 1)}) com os mesmos priors de eficácia e bolsões espelhados: ausentes trazidos nominalmente ({pct(0.8)} para ele), 3ª via e brancos/nulos permeáveis, eleitores brandos de Lula. O chamado aberto a votar (rádio, feira, igreja) tira ω × intensidade dos ausentes das UFs em que cada lado liderou, com a composição de quem falta (perfil mais pró-Lula, τ); na UF do adversário, o apelo genérico rende 30% e tem a composição local. O saldo líquido da resposta aparece em coluna própria. Como as duas redes usam os mesmos priors, a diferença entre as colunas mede só a assimetria de tamanho (ρ) e de base (Lula tem mais ausentes nos seus redutos; Flávio tem mais votos a proteger).</p></div>
<div class="variable"><h4>De onde vêm os parâmetros</h4><p>Os priors estão na tabela abaixo, com a fonte de cada um. Os que mais pesam são o número de voluntários (desconhecido: por isso a escada de cenários) e a conversão por contato, tomada da literatura de experimentos de campo: cerca de um voto a cada 14 contatos porta a porta para comparecimento, efeito quase nulo de persuasão em eleição geral e efeitos maiores quando o contato vem de alguém próximo. A 2ª opção da Quaest é de setembro; quando sair uma onda de 2º turno com a pergunta refeita, ela substitui os centros e a cadeia da campanha passa a começar no dia seguinte ao campo, para não contar duas vezes a conversa já feita.</p></div>
<div class="variable"><h4>O que isso muda na manchete</h4><p>Nada. A chance de Lula do painel continua vindo do modelo principal ({pct(p_prin) if p_prin is not None else "—"}) e do MCMC ({pct(p_mc) if p_mc is not None else "—"}), que partem das pesquisas e do voto contado. Esta seção responde a outra pergunta: dado o resultado de 4/out, quanto uma campanha de conversa pessoal com os três grupos pode mover, e o que precisaria acontecer para a virada. A versão "com deriva" da chance existe só para pôr as duas contas na mesma régua: sem campanha dirigida ela dá {pct(sem["p_lula_com_deriva"], 1)}, perto do modelo principal, um pouco abaixo porque não carrega a incerteza das pesquisas e dos efeitos de casa; o nível estrutural dos dois é o mesmo (reprodução do modelo de transferência: {fs(C["check_estrutural"]["recalculado"])} contra {fs(C["check_estrutural"]["esperado"]) if C["check_estrutural"]["esperado"] is not None else "—"}).</p></div>
</div>
<div class="table-card" style="margin-top:14px"><table class="mg-tab" style="min-width:900px">
<thead><tr><th>Parâmetro</th><th>Símbolo</th><th>Distribuição</th><th>Média</th><th>Desvio</th><th>Fonte ou justificativa</th></tr></thead>
<tbody>{priors_rows}</tbody></table></div>
<p class="nota">Concentrações das linhas Dirichlet: κ = 400 (Lula e Flávio), 100 (3ª via: subamostras da Quaest de 100 a 150 entrevistas, desvio de 4 a 5 p.p.), 150 (brancos/nulos e ausentes). Verificações do script: massa conservada em todos os sorteios ({"ok" if ch["massa_ok"] else "falhou"}); linhas somam 1 ({"ok" if ch["linhas_ok"] else "falhou"}); cadeia em [0, 1] e convertidos ≤ alcançados ({"ok" if ch["cadeia_ok"] else "falhou"}); reprodução do modelo estrutural ({"ok" if ch["estrutural_ok"] else "falhou"}); escada monotônica e resposta simétrica ≤ sem resposta ({"ok" if ch["monotonico_ok"] else "falhou"}); {f(Mg["segundos"], 0)} s.</p>
<h3 class="sub">Limites desta análise</h3>
<ul class="nota" style="padding-left:18px;font-size:13px">
<li><b>Não é previsão.</b> É uma análise condicional: dado o voto contado e uma campanha com estas características, para onde a margem iria. Não usa as pesquisas de 2º turno, que são a base da manchete, e não substitui as rodadas pós-1º turno.</li>
<li><b>Literatura americana.</b> As taxas de conversão vêm de experimentos de campo nos EUA, com voto facultativo; no Brasil, com voto obrigatório e 2º turno três semanas depois, o efeito de mobilização pode ser outro, e não há experimento comparável publicado.</li>
<li><b>Número de voluntários desconhecido.</b> Não existe medida da adesão a uma campanha sem coordenação central; a escada de 0 a 2 milhões cobre as ordens de grandeza, e o teto ψ impede que a corrente de convites cresça sem limite.</li>
<li><b>Transferência declarada.</b> Os centros das linhas da 3ª via vêm da matriz mais recente da base: a PoderData/Aya de 10–12/out, depois do 1º turno (a 2ª opção da Quaest, de setembro, vale só enquanto não há onda posterior). Zema não tem linha própria na PoderData e herda a do grupo "outro candidato". O Datafolha de 6–7/out publicou só o agregado dos quatro (Flávio 46, Lula 32), que dá a Lula 41% dos decididos, contra 45% na PoderData: reescalar a matriz para o agregado do Datafolha baixaria a chance de Lula no modelo principal de 15,9% para 14,3%.</li>
<li><b>Sem abstenção por município ou perfil.</b> A inclinação τ é um número nacional, calibrado na abstenção por escolaridade de 2022 e no voto por escolaridade das pesquisas; a composição real de quem entra e sai varia por lugar.</li>
<li><b>Resposta de Flávio suposta simétrica.</b> A rede dele é um múltiplo da do PT com os mesmos priors; uma campanha de outro tipo (mídia, igrejas, lideranças locais) não está modelada.</li>
<li><b>Exterior fora da campanha.</b> Os {mi(E["eleitorado"] - sum(r["eleitorado"] for r in sem["por_regiao"] if r["regiao"] != "EXT"))} eleitores no exterior entram na massa, mas não nos bolsões nem na inclinação de perfil.</li>
</ul>
<p class="nota">Fontes: TSE, resultado oficial do 1º turno por UF (100% das seções); PoderData/Aya 10–12/out (transferência declarada; antes, Quaest 3–6/set); TSE 2022 via CNN Brasil (abstenção por UF nos dois turnos); TSE, perfil do comparecimento 2022 por escolaridade; Gerber & Green, <i>Get Out the Vote</i>; Nickerson & Rogers (2010), <i>Psychological Science</i>; Kalla & Broockman (2018), <i>American Political Science Review</i>; Pereira et al. (Ipea, 2023) sobre o passe livre de 2022. Código: migracao_markov.py (cálculo, autoverificações e esta seção), chamado por modelo_eleicao_2026.py (bloco 5b).</p>
</section>
"""
def metodologia(R):
"""Passo 8 da metodologia (div.variable), no formato dos passos 1 a 7 de montar_painel.py."""
Mg = R.get("migracao") or {}
if not Mg.get("usado"): return ""
sem, forte = _cel(Mg, "sem", "nenhuma"), _cel(Mg, "forte", "nenhuma")
return f"""
<div class="variable">
<h4>8 · Migração por cadeia de Markov</h4>
<div class="formula">destinos<sub>u</sub> = n<sub>u</sub> · M<sub>u</sub> · M<sub>u</sub>[o, ·] ~ Dirichlet(κ<sub>o</sub> · m<sub>o</sub>) · m<sub>3ª via</sub> = 2ª opção + resíduo decidido (d<sub>3</sub>, s<sub>3</sub>) · m<sub>ausente</sub> = (r<sub>in</sub>π<sub>u</sub>, r<sub>in</sub>(1−π<sub>u</sub>), ·, 1−r<sub>in</sub>)<br>campanha: x<sub>t+1</sub> = x<sub>t</sub> · P<sub>p,t</sub> sobre S0…S4 · alcance D<sub>p</sub> = P<sub>p</sub>(1 − e<sup>−N/P<sub>p</sub></sup>) · conversão q<sub>p</sub> por contato, fases do guia · margem = 100(ΣL − ΣF)/(ΣL + ΣF)</div>
<p>Análise paralela (<code>migracao_markov.py</code>), condicional e sem efeito na manchete: a partir do resultado oficial por UF, uma [[markov|cadeia de Markov]] leva cada eleitor do 1º turno (Lula, Flávio, cada candidato da 3ª via, branco/nulo, ausente) a um dos quatro destinos do 2º turno por uma matriz sorteada linha a linha ([[dirichlet|Dirichlet]] em torno da 2ª opção declarada da Quaest, dos fluxos brutos de comparecimento e de uma inclinação de perfil). Uma segunda cadeia, diária e não homogênea, segue a campanha de conversa pessoal de 5 a 24/out (alcance por voluntários e recrutamento em cadeia; conversão por contato tomada dos experimentos de Gerber & Green, Nickerson & Rogers e Kalla & Broockman; reversão; confirmação) e aplica o estoque convertido como fluxo entre destinos, com teto na massa disponível. {Mg["n_sorteios"]//1000} mil sorteios por célula, em 4 cenários de alcance × 2 respostas de Flávio, mais a referência "metas do guia cumpridas". Sem campanha dirigida a margem mediana é {fs(sem["margem_q"]["50"])}; com a forte, {fs(forte["margem_q"]["50"])}. A chance "com deriva" soma a mesma deriva t<sub>5</sub> do passo 4, só para comparar com o modelo principal.</p>
</div>"""
# ------------------------------------------------------------------ verificação
if __name__ == "__main__":
import sys
DIR = os.path.dirname(os.path.abspath(__file__)) + "/"
tr = pd.read_csv(DIR + "transferencia_2turno.csv")
transf = {k: (float(g.pct_flavio.mean())/100, float(g.pct_lula.mean())/100) for k, g in tr.groupby("origem")}
comp = {}
if os.path.exists(DIR + "resultados.json"):
Rj = json.load(open(DIR + "resultados.json", encoding="utf-8"))
comp = {"estrutural_base_margem": Rj.get("estrutural_base_margem"), "p_lula_principal": Rj.get("p_lula"), "p_lula_mcmc": (Rj.get("mcmc") or {}).get("p_lula"),
"mediana_principal": (Rj.get("quantis_margem_final") or {}).get("50"), "sd_deriva_principal": Rj.get("sd_deriva")}
n_s = int(sys.argv[1]) if len(sys.argv) > 1 else 20_000
t0 = time.time()
A = simular(DIR, transf, "2026-10-06", comp.get("sd_deriva_principal") or 4.24, comp, seed=7, n_sorteios=n_s)
A2 = simular(DIR, transf, "2026-10-06", comp.get("sd_deriva_principal") or 4.24, comp, seed=7, n_sorteios=n_s)
ch = A["checks"]
print("[checks] " + " · ".join(f"{k}={'ok' if v else 'FALHOU'}" for k, v in ch.items()))
print(f"[estrutural] recalculado {A['comparacao']['check_estrutural']['recalculado']:+.2f} esperado {A['comparacao']['check_estrutural']['esperado']}")
assert A["cenarios"][0]["margem_q"] == A2["cenarios"][0]["margem_q"] and A["cenarios"][2]["saldo_frentes"] == A2["cenarios"][2]["saldo_frentes"], "semente fixa deveria reproduzir"
print("[ok] semente fixa reproduz")
for c in A["cenarios"]:
s = c["saldo_frentes"]
print(f" {c['nome']:<40} {c['resposta_nome']:<9} vol {c.get('voluntarios_q10_50_90', ['—'] * 3)[1]:>9} | saldo {s['total']['q50']/1e6:+.2f} mi "
f"(aus {s['ausentes']['q50']/1e6:+.2f}, 3v {s['terceira_via']['q50']/1e6:+.2f}, bn {s['brancos_nulos']['q50']/1e6:+.2f}, fl {s['eleitores_flavio']['q50']/1e6:+.2f}, "
f"ch {s['chamado_aberto']['q50']/1e6:+.2f}, resp {s['resposta_flavio']['q50']/1e6:+.2f}) | margem {c['margem_q']['50']:+.2f} [{c['margem_q']['10']:+.2f}, {c['margem_q']['90']:+.2f}] "
f"| P(Lula) {c['p_lula_so_migracao']:.3f} / com deriva {c['p_lula_com_deriva']:.3f} | abst {c['abstencao_q10_50_90'][1]:.1f}%")
print("[guia]", json.dumps(A["guia"]["taxa_necessaria"], ensure_ascii=False), "margem se cumprido", A["guia"]["margem_se_cumprido"])
print(f"[tempo] {A['segundos']} s por chamada ({time.time() - t0:.1f} s as duas)")
assert all(ch.values()), ch
# -*- coding: utf-8 -*-
"""
Agregador de pesquisas + modelo probabilístico — Eleição presidencial 2026 (Brasil). Versão 3 (auditoria de 30/set/2026).
Componentes
1. Dados: pesquisas de 2º turno (Lula × Flávio) e de 1º turno (todos os candidatos), calibração histórica 2006–2022
2. Calibração: qualidade por instituto (MAE histórico), viés sistemático das pesquisas por ciclo (âncora), movimento entre turnos
3. Tendência + efeitos de casa: espaço de estados único (espaco_estados.py) — passeio aleatório, efeitos de casa ridge
identificados pela âncora (institutos com histórico verificado), variância extra por instituto estimada nos dados
3c. Pesquisas estaduais → leitura nacional independente (swing sobre 2022)
3d. 1º turno: um estado por candidato (votos válidos), viés histórico do 1º turno, simulação → P(decide no 1º turno), P(Flávio no 2º)
4. Monte Carlo do 2º turno: nível + deriva em duas fases (até 4/out; entre turnos, calibrada em 2014/18/22) − viés + choques regionais
4b. Ensemble com pesos por regime (antes/depois do 1º turno) e tabela "o que mudou" (v2 → v3)
5. Tornado das variáveis decisivas · 6. O que o 1º turno dirá sobre o 2º · 6b. Diagnóstico por instituto · 7. Saídas
8. Regime pós-1º turno: ativado por resultado_1turno.csv (erro por instituto → priors do 2º turno)
5b. Migração (migracao_markov.py): ausentes, 3ª via e brancos/nulos por cadeia de Markov + Monte Carlo, análise paralela e condicional
Reprodutível: python3 modelo_eleicao_2026.py (HOJE pode ser fixado por variável de ambiente ELEICAO_HOJE=YYYY-MM-DD)
"""
import json, math, os, sys
import numpy as np
import pandas as pd
from scipy.stats import norm, beta as beta_dist
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from espaco_estados import ajustar, validacao_rolling
rng = np.random.default_rng(20261025)
HOJE = pd.Timestamp(os.environ.get("ELEICAO_HOJE") or pd.Timestamp.today().normalize())
T1 = pd.Timestamp("2026-10-04")
T2 = pd.Timestamp("2026-10-25")
DATA = os.path.dirname(os.path.abspath(__file__)) + "/" # bases (CSV) e previsão congelada: no repositório
OUT = os.environ.get("ELEICAO_OUT", "/tmp/outputs/modelo/") # saídas intermediárias (JSON, SVGs) para montar o painel
os.makedirs(OUT, exist_ok=True)
# Variantes do toggle do herói (19/out): com ELEICAO_SO_POS1T a base de 2º turno fica só com as ondas de campo depois de 4/out.
# ELEICAO_SO_POS1T=1 → sem o voto contado: o nível NÃO é combinado ao resultado do 1º turno + transferência (W_ESTRUT = 0);
# grava resultados_pos1t.json.
# ELEICAO_SO_POS1T=estrut → com o voto contado: mesma combinação do modelo principal; grava resultados_pos1t_estrut.json.
# As duas rodadas terminam logo depois do ensemble (sem MCMC, abstenção, tornado, migração, congelados, CSVs ou SVGs).
SO_POS1T = bool(os.environ.get("ELEICAO_SO_POS1T"))
POS1T_COM_ESTRUT = os.environ.get("ELEICAO_SO_POS1T") == "estrut"
# Variante "com momentum" (21/out): com ELEICAO_MOMENTUM=1 a deriva até 25/out ganha uma média, vinda do agregado das diferenças entre
# ondas consecutivas do mesmo instituto (mudanca_2turno.resumo, em p.p./dia), encolhida por ENCOLHE_MOMENTUM; o desvio dessa média é o
# erro-padrão do agregado (piso SD_MOMENTUM_PISO por dia), encolhido na mesma proporção, para não inflar a variância da deriva. O MCMC recebe a mesma prior. Grava resultados_momentum.json e termina
# depois do MCMC. No modelo principal (flag ausente) a deriva continua com média zero: nenhuma saída muda.
MOMENTUM = bool(os.environ.get("ELEICAO_MOMENTUM"))
ENCOLHE_MOMENTUM, SD_MOMENTUM_PISO = 0.5, 0.1
T0 = pd.Timestamp("2025-12-01") # origem do índice diário
INICIO_JANELA = pd.Timestamp("2026-06-01") # janela de campanha usada para o nível de hoje e para σ_rw
def dia(ts): return int((pd.Timestamp(ts) - T0).days)
t_now = dia(HOJE)
# ----------------------------------------------------------------------------
# 1. DADOS — 2º turno
# ----------------------------------------------------------------------------
df = pd.read_csv(DATA + "pesquisas_2turno_2026.csv", parse_dates=["campo_fim"])
df = df[df.campo_fim <= HOJE].copy()
df["margem_total"] = df.lula - df.flavio
df["lula_valid"] = 100 * df.lula / (df.lula + df.flavio)
df["margem_valid"] = 100 * (df.lula - df.flavio) / (df.lula + df.flavio)
df["idade_dias"] = (HOJE - df.campo_fim).dt.days
df["t"] = (df.campo_fim - T0).dt.days.astype(float)
df["dia"] = df.t.astype(int)
def _se_amostral(l_, f_, n_):
"""Erro padrão da margem em votos válidos m = 100(L−F)/(L+F) sob amostragem aleatória simples (multinomial)."""
pl, pf = l_/100.0, f_/100.0
return 100*math.sqrt((pl + pf - (pl - pf)**2/(pl + pf))/n_)/(pl + pf) # = 100·sqrt(4s(1−s)/(n·V)), s = L/(L+F), V = L+F
N_MAX_EFETIVO = 5000 # teto do tamanho de amostra efetivo: acima disso o erro não amostral domina (e uma onda não deve pesar 20× outra)
# Auditoria de 16/out: (a) a coluna opcional n_efetivo da base traz o n efetivo declarado pelo instituto (Palver: Kish, efeito dos pesos 2,8
# a 4,3 — o n bruto de 5.000 subestimava o erro em até 2×); (b) AtlasIntel (recrutamento digital aleatório, não probabilístico) declara
# ±1,0 p.p. com n≈5.000, mas a amostragem aleatória simples já dá ±1,4 e o efeito de desenho não é reportado: teto de 2.500.
N_EFETIVO_INST = {"AtlasIntel": 2500}
df["n_ef"] = (df["n_efetivo"] if "n_efetivo" in df.columns else df["n"]).fillna(df["n"]).astype(float)
df["n_ef"] = [min(r.n_ef, N_EFETIVO_INST.get(r.instituto, N_MAX_EFETIVO), N_MAX_EFETIVO) for r in df.itertuples()]
df["se_margem_teorico"] = [_se_amostral(r.lula, r.flavio, r.n_ef) for r in df.itertuples()]
df["se2"] = df.se_margem_teorico**2
# ----------------------------------------------------------------------------
# 2. CALIBRAÇÃO HISTÓRICA (calibracao_historica.csv: erro na margem esq−dir em válidos; + = superestimou o PT)
# ----------------------------------------------------------------------------
cal = pd.read_csv(DATA + "calibracao_historica.csv")
hist = cal.rename(columns={"ciclo": "eleicao", "erro_margem": "erro_pp"})[["instituto", "eleicao", "turno", "erro_pp", "referencia"]]
mae_inst = hist.groupby("instituto").erro_pp.apply(lambda s: s.abs().mean())
bias_inst = hist.groupby("instituto").erro_pp.mean()
n_inst = hist.groupby("instituto").size()
bias_el = hist.groupby(["eleicao", "turno"]).erro_pp.mean()
# Qualidade do instituto: erro absoluto médio EM RELAÇÃO À MÉDIA DO CICLO/TURNO (auditoria de 3/out). O MAE bruto mistura o erro
# próprio do instituto com o erro comum do ciclo (em 2014 e 2022 todos erraram o 1º turno em ~9 pontos), que já é tratado pelo
# desconto de viés; quem só tem histórico em ciclos "bons" parecia melhor. Só entram ciclos/turnos com 3 ou mais institutos.
_grp = hist.groupby(["eleicao", "turno"]).erro_pp
hist["erro_rel"] = hist.erro_pp - _grp.transform("mean")
_rel = hist[_grp.transform("size") >= 3]
mae_rel = _rel.groupby("instituto").erro_rel.apply(lambda s: s.abs().mean())
n_rel = _rel.groupby("instituto").size()
MAE_REF, Q_DEFAULT, K_SHRINK = round(float(mae_rel.median()), 2), 0.8, 2.0
def peso_qualidade(inst):
"""Qualidade = MAE relativo mediano / MAE relativo do instituto, encolhida para 0,8 conforme poucas eleições sustentam o MAE."""
if inst not in mae_rel.index:
return Q_DEFAULT
q_ = float(np.clip(MAE_REF / max(mae_rel[inst], 0.5), 0.6, 1.5))
k_ = float(n_rel[inst])
return float((k_ * q_ + K_SHRINK * Q_DEFAULT) / (k_ + K_SHRINK))
# Viés sistemático do 2º turno: média por ciclo dos institutos de referência (referencia=1), depois média entre ciclos
# com recência suave (meia-vida de dois ciclos = 8 anos). σ_b = desvio entre ciclos, com piso 2,0 pela mistura de métodos de 2026.
_h2 = hist[(hist.turno == 2) & (hist.referencia == 1)]
bias_2t_ciclos = _h2.groupby("eleicao").erro_pp.mean().to_dict()
MEIA_VIDA_CICLOS = 8.0
pesos_ciclo = {c_: 0.5 ** ((2022 - c_) / MEIA_VIDA_CICLOS) for c_ in bias_2t_ciclos}
BIAS_MEAN = round(sum(bias_2t_ciclos[c_]*pesos_ciclo[c_] for c_ in bias_2t_ciclos) / sum(pesos_ciclo.values()), 2)
# Desvio preditivo para um ciclo novo: desvio entre ciclos × sqrt(1 + 1/n) (a média também é estimada), com piso 2,0.
# As caudas são t de Student (DF_ERRO graus de liberdade, reescalada para o mesmo desvio): com 3 a 5 ciclos, a normal é otimista.
DF_ERRO = 5
def _t_unit(r_, n_): return r_.standard_t(DF_ERRO, n_) * math.sqrt((DF_ERRO - 2) / DF_ERRO)
def _sd_pred(v_): v_ = list(v_); return float(np.std(v_, ddof=1) * math.sqrt(1 + 1/len(v_)))
BIAS_SD = round(max(2.0, _sd_pred(bias_2t_ciclos.values())), 2)
BIAS_MEAN_IGUAL = round(float(np.mean(list(bias_2t_ciclos.values()))), 2)
# 1º turno: os ciclos verificáveis (2010, 2014, 2018, 2022) subestimaram o principal adversário do PT. Prior = 60% da média com
# recência: é uma HIPÓTESE (os institutos ajustam método entre ciclos — em 2026 vários ponderam pelo voto declarado de 2022 — e o
# sinal do erro pode mudar), por isso o desvio é o preditivo entre ciclos (≈ 3,8), não mais 3,0 fixo.
_h1 = hist[(hist.turno == 1) & (hist.referencia == 1)]
bias_1t_ciclos = _h1.groupby("eleicao").erro_pp.mean().to_dict()
_p1 = {c_: 0.5 ** ((2022 - c_) / MEIA_VIDA_CICLOS) for c_ in bias_1t_ciclos}
BIAS_1T_HIST = round(sum(bias_1t_ciclos[c_]*_p1[c_] for c_ in bias_1t_ciclos) / sum(_p1.values()), 2)
ENCOLHIMENTO_1T = 0.6
BIAS_1T_MEAN, BIAS_1T_SD = round(ENCOLHIMENTO_1T * BIAS_1T_HIST, 2), round(max(3.0, _sd_pred(bias_1t_ciclos.values())), 2)
# Viés por método — só diagnóstico (poucas observações de 2º turno para telefônico e online: não se aplica)
METODO_HIST = {"Datafolha": "presencial", "Quaest": "presencial", "Ipec": "presencial", "Ibope": "presencial", "MDA": "presencial",
"Vox Populi": "presencial", "Parana Pesquisas": "presencial", "AtlasIntel": "online", "PoderData": "telefonico",
"Ipespe": "telefonico", "Verita": "nao_informado", "Futura Inteligencia": "nao_informado"}
_h2m = hist[hist.turno == 2].assign(metodo=lambda d: d.instituto.map(METODO_HIST))
BIAS_METODO = {m_: round(float(g.erro_pp.mean()), 2) for m_, g in _h2m.groupby("metodo")}
BIAS_N_METODO = _h2m.groupby("metodo").size().to_dict()
# Movimento entre turnos (pesquisas de 2º turno do mesmo instituto: primeira após o 1º turno → última antes do 2º)
mov = pd.read_csv(DATA + "movimento_entre_turnos.csv")
_se_pol = 2.2 # erro amostral típico da margem (n≈2.000) — cada delta observado carrega 2 erros de pesquisa
_var_obs = float((mov.delta_margem**2).mean()); _dias_med = float(mov.dias.mean())
SIG_ENTRE_DIA_BRUTO = math.sqrt(max(_var_obs - 2*_se_pol**2, 1.0) / _dias_med)
# Âncora: institutos com histórico presidencial verificado (referencia=1 no 2º turno) presentes na base de 2026
ANCORA = sorted(set(_h2.instituto) & set(df.instituto))
Q_ANCORA = {p_: peso_qualidade(p_) for p_ in ANCORA}
if SO_POS1T:
# Variante pós-1º turno: o filtro entra DEPOIS da âncora e dos pesos de qualidade, que ficam os do modelo principal (ajustar ignora
# âncoras sem observação), de modo que a seção 3d (1º turno) sai idêntica; só a série de 2º turno muda. Sem ondas pré-4/out,
# _inst_pre1t fica vazio e a_casa = 1 em todas: cada onda identifica o efeito de casa inteiro, encolhido ao prior do erro do 1º turno.
assert HOJE >= T1, "variante pós-1º turno só faz sentido depois de 4/out"
df = df[df.campo_fim > T1].copy()
assert len(df) >= 5, "variante pós-1º turno: menos de 5 pesquisas depois de 4/out"
assert set(ANCORA) & set(df.instituto), "variante pós-1º turno: nenhuma pesquisa da âncora depois de 4/out"
# ----------------------------------------------------------------------------
# 3. TENDÊNCIA + EFEITOS DE CASA — espaço de estados único (Jackman 2005; Linzer 2013; Economist 2020)
# ----------------------------------------------------------------------------
# Regime pós-1º turno (seção 8): priors de efeito de casa vindos do erro observado no 1º turno
res1t_path = DATA + "resultado_1turno.csv"
RESULTADO_1T = None
if os.path.exists(res1t_path):
_r1 = pd.read_csv(res1t_path)
_r1["votos_validos_pct"] = pd.to_numeric(_r1.votos_validos_pct, errors="coerce")
if len(_r1) and _r1.votos_validos_pct.notna().any():
RESULTADO_1T = dict(zip(_r1.candidato.str.lower(), _r1.votos_validos_pct))
POS_1T = RESULTADO_1T is not None and HOJE >= T1
if HOJE > T1 and not POS_1T:
print("[AVISO] já passou o 1º turno e resultado_1turno.csv está vazio: o modelo segue no regime pré-1º turno.")
prior_h_2t, tau_prior_h = {}, None
# Persistência do efeito de casa depois do 1º turno (auditoria de 16/out, estimada nas 14 pesquisas pós-4/out que havia então). A inclinação
# da margem observada sobre o efeito de casa estimado antes do 1º turno era +0,58 ± 0,68 (+0,13 ± 1,0 sem Gerp e Palver; persistência
# total = 1, nula = 0), e a dispersão entre institutos AUMENTAVA depois da correção (DP 0,8 → 2,1 na semana de 10–14/out, sem a Gerp):
# vários institutos passaram a pesar pelo voto declarado no 1º turno (Palver v3, Ipespe). λ = 0,5 entra como compromisso entre a
# persistência total do modelo antigo e o zero que os dados também comportam; a observação pós-1º turno identifica λ·h, não h.
# λ só faz sentido para quem tem efeito de casa estimado ANTES do 1º turno: uma série nova (Palver v3, MDA v2, só com ondas pós-4/out)
# não tem efeito anterior para "persistir", e nela a observação identifica h inteiro (a_casa = 1).
LAMBDA_CASA_POS1T = 0.5
LAMBDA_CASA = LAMBDA_CASA_POS1T if (RESULTADO_1T is not None and HOJE >= T1) else 1.0
_inst_pre1t = set(df.loc[df.campo_fim <= T1, "instituto"])
df["a_casa"] = np.where((df.campo_fim > T1) & df.instituto.isin(_inst_pre1t), LAMBDA_CASA, 1.0)
FLAVIO_TOP2_REAL = None
ERRO_COMUM_1T = None # erro médio da âncora na margem Lula − Flávio do 1º turno (+ = superestimou Lula)
if POS_1T:
# Seção 8: o erro de cada instituto na margem Lula − Flávio do 1º turno (última onda até 4/out, em válidos), medido em relação
# à média da âncora, vira prior do efeito de casa no 2º turno, com metade do peso (o erro de um turno só prevê em parte o do outro).
_real = RESULTADO_1T.get("lula", np.nan) - RESULTADO_1T.get("flavio", np.nan)
_ord = sorted(RESULTADO_1T, key=lambda k_: -(RESULTADO_1T[k_] if pd.notna(RESULTADO_1T[k_]) else -1))
FLAVIO_TOP2_REAL = "flavio" in _ord[:2]
_p1e = pd.read_csv(DATA + "pesquisas_1turno_2026.csv", parse_dates=["campo_fim"])
_p1e = _p1e[_p1e.campo_fim <= T1].sort_values("campo_fim").groupby("instituto").tail(1)
_cand_cols = ["lula", "flavio", "cury", "renan", "caiado", "zema", "outros"]
_val = _p1e[_cand_cols].sum(axis=1)
_err = dict(zip(_p1e.instituto, 100*(_p1e.lula - _p1e.flavio)/_val - _real))
_anc = [v for k_, v in _err.items() if k_ in set(_h2.instituto)]
_c0 = float(np.mean(_anc)) if _anc else float(np.mean(list(_err.values())))
if np.isfinite(_real):
ERRO_COMUM_1T = _c0
prior_h_2t = {k_: 0.5*(v - _c0) for k_, v in _err.items() if np.isfinite(v)}
tau_prior_h = 1.5
# As séries novas do 2º turno ("Palver v3", "MDA v2", "Futura Inteligencia v2") não recebem este prior (a chave é o nome do 1º turno):
# entram com prior 0 e τ = 1,5, de propósito, porque mudaram a ponderação e o erro do 1º turno foi medido com a régua antiga.
# Quebra estrutural: as simulações de 2º turno feitas antes do 1º turno e as pesquisas feitas depois não são a mesma série (em
# 2014, 2018 e 2022 o salto foi de 9 a 14 pontos). No regime pós-1º turno o passeio recebe variância extra de 6² no dia seguinte
# a 4/out, de modo que o nível passa a seguir as pesquisas novas em vez de levar semanas para se afastar das antigas.
SD_QUEBRA_1T = 6.0
Q_EXTRA = {dia(T1) + 1: SD_QUEBRA_1T**2} if POS_1T else {}
fit_full = ajustar(df.margem_valid.values, df.dia.values, df.se2.values, df.instituto.values, set(ANCORA), Q_ANCORA, n_days=t_now + 1, q_extra=Q_EXTRA,
a_h=df.a_casa.values)
jan = df[df.campo_fim >= INICIO_JANELA].copy()
d0_jan = int(jan.dia.min())
Q_EXTRA_WIN = {k_ - d0_jan: v for k_, v in Q_EXTRA.items()}
def _ajusta_janela(sub_):
return ajustar(sub_.margem_valid.values, (sub_.dia - d0_jan).values, sub_.se2.values, sub_.instituto.values, set(ANCORA), Q_ANCORA,
n_days=t_now - d0_jan + 1, prior_h=prior_h_2t, tau_prior_h=tau_prior_h, q_extra=Q_EXTRA_WIN, a_h=sub_.a_casa.values)
fit_win = _ajusta_janela(jan)
def x_win(d_): # nível suavizado da janela no dia absoluto d_
k_ = int(np.clip(d_ - d0_jan, 0, fit_win["n_days"] - 1)); return float(fit_win["x"][k_]), float(fit_win["P"][k_])
level_nac, P_nac = x_win(t_now)
se_nac = math.sqrt(P_nac + fit_win["var_ancora"]) # o filtro trata os efeitos de casa como conhecidos; soma a incerteza da média da âncora
house = dict(fit_full["house"]); house.update(fit_win["house"]) # janela prevalece; institutos só antigos ficam com o ajuste completo
sigma_inst = dict(fit_full["sigma_inst"]); sigma_inst.update(fit_win["sigma_inst"])
SIG_RW_KF, SIG_RW_ML = fit_win["sig_rw"], fit_win["sig_rw_ml"]
insts = list(df.instituto.unique())
# casa (instituto sem o sufixo " vN"): "Palver v3" e "MDA v2" são séries novas só para o efeito de casa; nos contadores onda a onda
# e na "última pesquisa por instituto" continuam a Palver e a CNT/MDA. "Futura Inteligencia v2" (21/out): a partir da onda de 19–20/out a Futura
# pondera escolaridade pela PNAD Contínua em vez do cadastro do TSE (mudança declarada no relatório), série nova pelo mesmo critério
df["inst_base"] = df.instituto.str.replace(r" v\d$", "", regex=True)
df["house"] = df.instituto.map(house)
df["house_aplicado"] = df.house * df.a_casa # o que de fato desconta a margem de cada pesquisa (λ·h depois do 1º turno)
df["sigma_inst"] = df.instituto.map(sigma_inst)
df["w_precisao"] = 1.0 / (df.se2 + df.sigma_inst**2)
df["w_recencia"] = np.exp(-np.log(2) * df.idade_dias / 21.0) # só para exibição (a recência entra pelo passeio aleatório)
df["w_qualidade"] = df.instituto.map(peso_qualidade)
df["w_tamanho"] = np.clip(np.sqrt(df.n / 2000.0), 0.7, 1.5)
df["w"] = df.w_recencia * df.w_precisao / df.w_precisao.mean()
print(f"[Espaço de estados] janela desde {INICIO_JANELA.date()}: {len(jan)} pesquisas | σ_rw MAP={SIG_RW_KF:.3f} (ML {SIG_RW_ML:.3f}) p.p./dia | "
f"nível hoje={level_nac:+.2f} ± {se_nac:.2f} | âncora={ANCORA} | iterações {fit_win['iter']}")
level_kf, se_kf = level_nac, se_nac
level_pspline, se_pspline = float(fit_full["x"][t_now]), math.sqrt(float(fit_full["P"][t_now])) # ajuste completo (dez–set), para comparação
# média ponderada simples (última pesquisa por casa, ajustada pelo efeito de casa da série a que ela pertence) — para comparação
last = df.sort_values("campo_fim").groupby("inst_base").tail(1).copy()
last["margem_adj"] = last.margem_valid - last.house_aplicado
last["w_last"] = last.w
media_simples_adj = float((last.margem_adj * last.w_last).sum() / last.w_last.sum())
media_simples_bruta = float((last.margem_valid * last.w_last).sum() / last.w_last.sum())
# validação de origem móvel (últimos 60 dias, passo 3, horizonte 7 dias) — cobertura e viés das previsões do próprio modelo
# Mesmo modelo do ajuste da janela (prior do efeito de casa vindo do erro do 1º turno e λ nas ondas pós-1º turno): antes a validação
# rodava sem os dois e media um modelo diferente do que o painel usa.
_rv = [] if SO_POS1T else validacao_rolling(jan.margem_valid.values, (jan.dia - d0_jan).values, jan.se2.values, jan.instituto.values, set(ANCORA), Q_ANCORA,
dias=range(max(t_now - d0_jan - 60, 25), t_now - d0_jan - 6, 3), q_extra=Q_EXTRA_WIN,
prior_h=prior_h_2t, tau_prior_h=tau_prior_h, a_h=jan.a_casa.values)
_z = np.array([(o_ - p_)/s_ for _, o_, p_, s_, _, _ in _rv]) if _rv else np.array([0.0])
# viés separado: institutos que o ajuste já conhecia × institutos novos (entram com efeito de casa 0). Se o viés está só nos novos,
# é composição da base; se está também nos conhecidos, é tendência que o passeio aleatório (sem inclinação) não antecipa.
_ev = [o_ - p_ for _, o_, p_, _, _, v_ in _rv if v_]; _en = [o_ - p_ for _, o_, p_, _, _, v_ in _rv if not v_]
VALIDACAO = {"n_previsoes": len(_rv), "vies_medio": round(float(np.mean([o_ - p_ for _, o_, p_, _, _, _ in _rv])), 2) if _rv else None,
"vies_inst_vistos": round(float(np.mean(_ev)), 2) if _ev else None, "n_inst_vistos": len(_ev),
"vies_inst_novos": round(float(np.mean(_en)), 2) if _en else None, "n_inst_novos": len(_en),
"cobertura_80": round(float(np.mean(np.abs(_z) < 1.2816)), 3), "cobertura_95": round(float(np.mean(np.abs(_z) < 1.96)), 3),
"erro_abs_medio": round(float(np.mean([abs(o_ - p_) for _, o_, p_, _, _, _ in _rv])), 2) if _rv else None,
"sd_z": round(float(_z.std()), 2), "horizonte_dias": 7}
print(f"[Validação] {VALIDACAO}")
# curva para o gráfico (ajuste completo dez→hoje)
tt_grid = np.arange(int(df.dia.min()), t_now + 1)
f_grid = fit_full["x"][tt_grid]
se_grid = np.sqrt(fit_full["P"][tt_grid])
# ----------------------------------------------------------------------------
# 3c. PESQUISAS ESTADUAIS → estimativa nacional independente (swing sobre o 2º turno de 2022 por UF)
# ----------------------------------------------------------------------------
MARGEM_2022 = 1.80
SIG_EXTRA_UF, SIG_SIST_UF, RHO_EST_NAC, COBERTURA_MIN, HALF_LIFE = 3.0, 1.5, 0.3, 0.30, 21.0
ref_uf = pd.read_csv(DATA + "referencia_uf.csv") if os.path.exists(DATA + "referencia_uf.csv") else None
est = pd.read_csv(DATA + "pesquisas_estaduais_2026.csv", parse_dates=["campo_fim"]) if os.path.exists(DATA + "pesquisas_estaduais_2026.csv") else None
estadual = {"usado": False, "motivo": "base estadual ausente"}
REGIAO_UF, PESO_UF = {}, {}
if ref_uf is not None:
ref_uf["peso"] = ref_uf.eleitorado / ref_uf.eleitorado.sum()
ref_uf["peso_v"] = ref_uf.votos_validos_2022 / ref_uf.votos_validos_2022.sum() # a margem nacional é média ponderada por votos válidos
ref_uf["m22"] = ref_uf.lula_2022 - ref_uf.bolsonaro_2022
REGIAO_UF = dict(zip(ref_uf.uf, ref_uf.regiao)); PESO_UF = dict(zip(ref_uf.uf, ref_uf.peso))
if ref_uf is not None and est is not None:
e2 = est[(est.turno == 2) & ((HOJE - est.campo_fim).dt.days <= 45) & (est.campo_fim <= HOJE)].copy()
if POS_1T:
e2 = e2[e2.campo_fim > T1] # a quebra de 4/out vale também para as estaduais: só entram as feitas depois do 1º turno
e2 = e2.merge(ref_uf[["uf", "m22", "peso", "peso_v", "regiao"]], on="uf", how="inner")
if len(e2):
e2["margem_bruta"] = 100*(e2.lula - e2.flavio)/(e2.lula + e2.flavio)
e2["t"] = (e2.campo_fim - T0).dt.days.astype(float)
e2["ajuste_casa"] = -e2.instituto.map(house).fillna(0.0)
e2["ajuste_tend"] = [level_nac - x_win(int(tt))[0] for tt in e2.t]
e2["margem_valid"] = e2.margem_bruta + e2.ajuste_casa + e2.ajuste_tend
e2["swing"] = e2.margem_valid - e2.m22
e2["w"] = (np.exp(-np.log(2)*(HOJE - e2.campo_fim).dt.days/HALF_LIFE) * np.clip(np.sqrt(e2.n/1000), 0.6, 1.5)
* e2.instituto.map(lambda i_: peso_qualidade(i_)))
e2["var_i"] = [_se_amostral(r.lula, r.flavio, r.n)**2 + SIG_EXTRA_UF**2 for r in e2.itertuples()]
por_uf = []
for uf_, g in e2.groupby("uf"):
wv = g.w/g.var_i
s_raw = float((g.swing*wv).sum()/wv.sum())
v_raw = float(1/(g.w/g.var_i).sum()*g.w.sum()/len(g)) if len(g) > 1 else float(g.var_i.iloc[0])
por_uf.append({"uf": uf_, "n_pesq": int(len(g)), "swing_bruto": s_raw, "var": v_raw, "peso": float(g.peso.iloc[0]), "peso_v": float(g.peso_v.iloc[0]),
"m22": float(g.m22.iloc[0]), "margem_2026": float((g.margem_valid*g.w).sum()/g.w.sum()),
"institutos": ", ".join(sorted(g.instituto.unique())), "ultimo_campo": str(g.campo_fim.max().date())})
pu = pd.DataFrame(por_uf)
wi = 1/pu["var"]; mu_fe = float((pu.swing_bruto*wi).sum()/wi.sum())
Qh = float((wi*(pu.swing_bruto - mu_fe)**2).sum()); c_ = float(wi.sum() - (wi**2).sum()/wi.sum())
tau2 = max(0.0, (Qh - (len(pu) - 1))/c_) if len(pu) > 1 and c_ > 0 else 0.0
wr = 1/(pu["var"] + tau2); mu_s = float((pu.swing_bruto*wr).sum()/wr.sum()); var_mu = float(1/wr.sum())
pu["regiao"] = pu.uf.map(REGIAO_UF)
mu_reg = {}
for r_, g_ in pu.groupby("regiao"):
wg = 1/(g_["var"] + tau2); m_r = float((g_.swing_bruto*wg).sum()/wg.sum()); v_r = float(1/wg.sum())
k_r = (tau2/2)/((tau2/2) + v_r) if tau2 > 0 else 0.0
mu_reg[r_] = mu_s + k_r*(m_r - mu_s)
pu["mu_regiao"] = pu.regiao.map(mu_reg)
pu["swing"] = pu.mu_regiao + (tau2/2)/((tau2/2) + pu["var"])*(pu.swing_bruto - pu.mu_regiao)
pu["var_post"] = 1/(1/max(tau2/2, 1e-6) + 1/pu["var"])
cobertura = float(pu.peso.sum()); cobertura_v = float(pu.peso_v.sum())
_sem = ref_uf[~ref_uf.uf.isin(pu.uf)]
swing_sem = float(sum(r_.peso_v*mu_reg.get(r_.regiao, mu_s) for r_ in _sem.itertuples()))
swing_nac = float((pu.swing*pu.peso_v).sum() + swing_sem)
var_est = float((pu.peso_v**2*pu.var_post).sum() + (1 - cobertura_v)**2*(var_mu + tau2/2) + SIG_SIST_UF**2)
nivel_est, se_est = MARGEM_2022 + swing_nac, math.sqrt(var_est)
estadual = {"usado": cobertura >= COBERTURA_MIN, "motivo": "" if cobertura >= COBERTURA_MIN else f"cobertura {cobertura:.0%} < {COBERTURA_MIN:.0%}",
"nivel": nivel_est, "se": se_est, "mu_swing": mu_s, "tau": math.sqrt(tau2), "cobertura": cobertura,
"n_pesq": int(len(e2)), "n_uf": int(len(pu)), "por_uf": pu.sort_values("peso", ascending=False),
"mu_regiao": {k_: round(v, 2) for k_, v in mu_reg.items()},
"ajuste_medio_tend": float((e2.ajuste_tend).mean()), "ajuste_medio_casa": float(e2.ajuste_casa.mean())}
print(f"[Estadual] {len(e2)} pesquisas em {len(pu)} UFs (cobertura {cobertura:.0%}) | swing médio {mu_s:+.2f} τ={math.sqrt(tau2):.2f} | nacional {nivel_est:+.2f} ± {se_est:.2f}")
level_final, se_final = level_nac, se_nac
w_est_comb = 0.0
if estadual["usado"]:
s1, s2 = se_nac, estadual["se"]
den = s1**2 + s2**2 - 2*RHO_EST_NAC*s1*s2
w_est_comb = float(np.clip((s1**2 - RHO_EST_NAC*s1*s2)/den, 0, 1))
level_final = (1 - w_est_comb)*level_nac + w_est_comb*estadual["nivel"]
se_final = math.sqrt(s1**2*s2**2*(1 - RHO_EST_NAC**2)/den)
estadual["peso_na_combinacao"] = w_est_comb
# ----------------------------------------------------------------------------
# 3d. 1º TURNO — um estado por candidato (votos válidos), efeitos de casa por instituto × candidato, viés histórico, simulação
# ----------------------------------------------------------------------------
CANDS = ["lula", "flavio", "cury", "renan", "caiado", "zema", "outros"]
CAND_LABEL = {"lula": "Lula", "flavio": "Flávio Bolsonaro", "cury": "Augusto Cury", "renan": "Renan Santos", "caiado": "Ronaldo Caiado",
"zema": "Romeu Zema", "outros": "Outros"}
p1 = pd.read_csv(DATA + "pesquisas_1turno_2026.csv", parse_dates=["campo_fim"])
p1 = p1[(p1.campo_fim <= HOJE)].sort_values("campo_fim").reset_index(drop=True)
# Ondas em que só Lula e Flávio foram publicados: os demais candidatos são imputados (última onda completa do mesmo instituto ou,
# na falta, média recente entre institutos) apenas para calcular os válidos; o candidato imputado NÃO entra no seu próprio ajuste.
_terc = ["cury", "renan", "caiado", "zema", "outros"]
_obs = {c_: p1[c_].notna().values.copy() for c_ in CANDS} # máscara antes de qualquer imputação (inclusive de "outros")
p1["imputada"] = p1[["cury", "renan", "caiado", "zema"]].isna().any(axis=1)
for i_, r_ in p1.iterrows():
for c_ in _terc:
if pd.isna(r_[c_]):
prev = p1[(p1.instituto == r_.instituto) & (p1.index < i_) & p1[c_].notna()]
ref = p1[(p1.index < i_) & p1[c_].notna()].tail(8)
p1.loc[i_, c_] = float(prev[c_].iloc[-1]) if len(prev) else (float(ref[c_].mean()) if len(ref) else 0.0)
p1["validos"] = p1[CANDS].sum(axis=1)
p1["n_valid"] = np.minimum(p1.n, N_MAX_EFETIVO) * p1.validos / 100.0 # teto de n: amostras gigantes (Veritá 40.500) não dominam
for c_ in CANDS: p1[c_ + "_v"] = 100 * p1[c_] / p1.validos
p1["dia"] = (p1.campo_fim - T0).dt.days.astype(int)
p1["margem_v"] = p1.lula_v - p1.flavio_v
d0_1t = int(p1.dia.min())
JANELA_MENORES = 28 # dias: o nível dos candidatos menores usa só as ondas recentes (depois da quebra de Cury no início de setembro)
def _fit_cand(c_, sig_rw_fixo=None, desde=None):
m_ = _obs[c_] & ((p1.campo_fim >= desde).values if desde is not None else True)
sub_ = p1[m_]
pv = sub_[c_ + "_v"].values / 100.0
se2c = np.maximum(pv * (1 - pv) / sub_.n_valid.values, 1e-6) * 1e4
d0_ = int(sub_.dia.min())
fit_ = ajustar(sub_[c_ + "_v"].values, (sub_.dia - d0_).values, se2c, sub_.instituto.values, set(ANCORA), Q_ANCORA,
n_days=t_now - d0_ + 1, tau_h=1.2, sigma_floor=0.6, sig_rw_fixo=sig_rw_fixo)
fit_["d0"] = d0_
return fit_
fit1 = {c_: _fit_cand(c_) for c_ in CANDS}
# Candidatos menores: σ_rw comum (mediana das estimativas individuais). Com σ próprio, uma quebra estrutural (Cury: 1–2% → 10% → 3%)
# leva o passeio de um candidato a ~1 p.p./dia e o nível passa a copiar a última pesquisa, o que fazia o 3º lugar oscilar a cada onda.
MENORES = ["cury", "renan", "caiado", "zema", "outros"]
SIG_RW_MENORES_LIVRE = {c_: round(float(fit1[c_]["sig_rw"]), 3) for c_ in MENORES}
SIG_RW_MENORES = float(np.median(list(SIG_RW_MENORES_LIVRE.values())))
for c_ in MENORES:
fit1[c_] = _fit_cand(c_, SIG_RW_MENORES, desde=min(HOJE, T1) - pd.Timedelta(days=JANELA_MENORES)) # depois de 4/out não há pesquisa de 1º turno: a janela fica ancorada em T1
share_now = {c_: float(fit1[c_]["x"][t_now - fit1[c_]["d0"]]) for c_ in CANDS}
se_share = {c_: math.sqrt(float(fit1[c_]["P"][t_now - fit1[c_]["d0"]])) for c_ in CANDS}
# Lula e Flávio (auditoria de 3/out): em vez de duas séries independentes, o espaço de estados é ajustado na MARGEM Lula − Flávio
# (como no 2º turno: o efeito de casa e o viés histórico são da margem) e na SOMA dos dois (quanto sobra para os demais).
# Lula = (soma + margem)/2, Flávio = (soma − margem)/2. Duas séries independentes ignoravam que os dois disputam o mesmo voto.
_pl, _pf = p1.lula_v/100.0, p1.flavio_v/100.0
p1["soma_v"] = p1.lula_v + p1.flavio_v
def _fit_par(col_, se2_, tau_, piso_, sub_=None):
sub_ = p1 if sub_ is None else sub_
f_ = ajustar(sub_[col_].values, (sub_.dia - d0_1t).values, se2_[sub_.index.values], sub_.instituto.values, set(ANCORA), Q_ANCORA,
n_days=t_now - d0_1t + 1, tau_h=tau_, sigma_floor=piso_)
return f_
_se2_m1 = ((_pl + _pf - (_pl - _pf)**2) / p1.n_valid * 1e4).values
_se2_s1 = (np.maximum((_pl + _pf)*(1 - _pl - _pf), 1e-4) / p1.n_valid * 1e4).values
fit_m1 = _fit_par("margem_v", _se2_m1, 1.5, 0.8)
fit_s1 = _fit_par("soma_v", _se2_s1, 1.2, 0.6)
_m1, _s1 = float(fit_m1["x"][-1]), float(fit_s1["x"][-1])
SE_M1 = math.sqrt(float(fit_m1["P"][-1]) + fit_m1["var_ancora"]); SE_S1 = math.sqrt(float(fit_s1["P"][-1]) + fit_s1["var_ancora"])
share_now["lula"], share_now["flavio"] = (_s1 + _m1)/2, (_s1 - _m1)/2
se_share["lula"] = se_share["flavio"] = math.sqrt(SE_M1**2 + SE_S1**2)/2
_norm = sum(share_now.values())
share_now = {c_: 100 * v / _norm for c_, v in share_now.items()} # renormaliza (compositional)
house_1t = {c_: fit1[c_]["house"] for c_ in CANDS}
sig_rw_1t = float(np.median([fit1[c_]["sig_rw"] for c_ in CANDS]))
# última pesquisa por instituto (totais e válidos) — para a tabela do painel
last1 = p1.sort_values("campo_fim").groupby("instituto").tail(1).sort_values("campo_fim", ascending=False)
# agregado em totais (para o modelo de transferência): brancos/indecisos = média ponderada por recência das últimas ondas
_w1 = np.exp(-np.log(2) * (HOJE - last1.campo_fim).dt.days / 14.0)
agg1 = {c_: float((last1[c_] * _w1).sum() / _w1[last1[c_].notna()].sum()) for c_ in CANDS + ["branco_nulo", "indecisos"]} # NaN fora do numerador e do denominador
_tot = sum(agg1[c_] for c_ in CANDS)
_resto = 100 - agg1["branco_nulo"] - agg1["indecisos"]
agg1.update({c_: share_now[c_] * _resto / 100 for c_ in CANDS}) # totais coerentes com o nível de hoje em válidos
agg1["L"], agg1["F"], agg1["branco"], agg1["indec"] = agg1["lula"], agg1["flavio"], agg1["branco_nulo"], agg1["indecisos"]
if POS_1T:
# Depois do 1º turno o modelo de transferência parte do RESULTADO OFICIAL (votos válidos), não mais do agregado de pesquisas.
_rv1 = {c_: float(RESULTADO_1T.get(c_, np.nan)) for c_ in CANDS}
_conh = sum(v for v in _rv1.values() if np.isfinite(v))
_rv1 = {c_: (v if np.isfinite(v) else (max(100 - _conh, 0.0) if c_ == "outros" else 0.0)) for c_, v in _rv1.items()}
agg1.update({c_: _rv1[c_] * _resto / 100 for c_ in CANDS})
agg1["L"], agg1["F"] = agg1["lula"], agg1["flavio"]
for k_, v_ in [("Cury", "cury"), ("Renan", "renan"), ("Caiado", "caiado"), ("Zema", "zema")]:
agg1[k_] = agg1[v_]
L_valid_now = share_now["lula"]; gap_valid = 50.0 - L_valid_now
# matriz de transferência declarada (2ª opção, Quaest 3–6/set/2026): % dos eleitores de cada candidato que migram p/ (Flávio, Lula)
# Lida de transferencia_2turno.csv (instituto, campo_fim, origem, pct_flavio, pct_lula). Havendo ondas com campo depois do 1º turno,
# só elas entram (média por origem); antes disso vale a 2ª opção declarada.
_tr = pd.read_csv(DATA + "transferencia_2turno.csv", parse_dates=["campo_fim"])
_tr = _tr[_tr.campo_fim <= HOJE]
if (_tr.campo_fim > T1).any(): _tr = _tr[_tr.campo_fim > T1]
TRANSF_FONTE = "; ".join(sorted(set(f"{r_.instituto} {r_.campo_fim:%d/%m}" for r_ in _tr.itertuples())))
transf = {k_: (float(g_.pct_flavio.mean())/100, float(g_.pct_lula.mean())/100) for k_, g_ in _tr.groupby("origem")}
assert set(transf) == {"cury", "renan", "caiado", "zema", "outros"}, "transferencia_2turno.csv precisa cobrir as cinco origens"
share_lula_3via = {k_: transf[k_][1]/(transf[k_][0]+transf[k_][1]) for k_ in transf}
T3 = {k_: share_now[k_] for k_ in transf}; T3_total = sum(T3.values())
# Simulação do 1º turno (200 mil): nível + incerteza + deriva até 4/out + colapso tardio da 3ª via (voto útil) + viés histórico,
# renormalizado a 100 (compositional). O viés entra na margem Lula−Flávio: −b/2 em Lula, +b/2 em Flávio.
M1 = 200_000
r1 = np.random.default_rng(41)
dias_1t = max((T1 - HOJE).days, 0)
sim1 = {}
_m_sim = r1.normal(share_now["lula"] - share_now["flavio"], math.sqrt(SE_M1**2 + dias_1t * fit_m1["sig_rw"]**2), M1)
_s_sim = r1.normal(share_now["lula"] + share_now["flavio"], math.sqrt(SE_S1**2 + dias_1t * fit_s1["sig_rw"]**2), M1)
sim1["lula"], sim1["flavio"] = (_s_sim + _m_sim)/2, (_s_sim - _m_sim)/2
for c_ in CANDS:
_sig_dia = fit1[c_]["sig_rw"]
if c_ in ("lula", "flavio"):
continue
else:
# menores: incerteza extra de reta final proporcional ao tamanho (1,5 p.p. para quem tem 5%), sorteada em escala
# log-normal com a mesma média e o mesmo desvio, para não gerar parcelas negativas nem exigir corte e renormalização
_sd = math.sqrt(se_share[c_]**2 + dias_1t * _sig_dia**2 + (1.5*math.sqrt(max(share_now[c_], 0.1)/5.0))**2)
_mu = max(share_now[c_], 0.05); _s = math.sqrt(math.log(1 + (_sd/_mu)**2))
sim1[c_] = _mu * np.exp(r1.normal(-_s**2/2, _s, M1))
colapso = beta_dist.rvs(2, 8, size=M1, random_state=r1) # fração da 3ª via que migra na reta final (média 0,20)
mig_l = np.zeros(M1); mig_f = np.zeros(M1)
for k_ in transf:
m_ = sim1[k_] * colapso
sim1[k_] = sim1[k_] - m_
mig_l += m_ * share_lula_3via[k_]; mig_f += m_ * (1 - share_lula_3via[k_])
# O viés histórico do 1º turno compara as pesquisas finais com o resultado e por isso JÁ inclui a migração tardia da 3ª via
# (2014: Marina → Aécio; 2022: Ciro/Tebet → Bolsonaro). Para não contar duas vezes, o colapso redistribui votos com efeito
# médio nulo na margem Lula − Flávio: mantém a dispersão (quanto e para quem migra varia), mas o deslocamento médio fica no viés.
_desloc_colapso = float((mig_l - mig_f).mean())
mig_l -= _desloc_colapso/2; mig_f += _desloc_colapso/2
b1 = BIAS_1T_MEAN + BIAS_1T_SD*_t_unit(r1, M1) if not POS_1T else np.zeros(M1)
sim1["lula"] = sim1["lula"] + mig_l - b1/2
sim1["flavio"] = sim1["flavio"] + mig_f + b1/2
for c_ in ("lula", "flavio"): sim1[c_] = np.clip(sim1[c_], 0.1, None)
_tot1 = sum(sim1[c_] for c_ in CANDS)
for c_ in CANDS: sim1[c_] = 100 * sim1[c_] / _tot1
lula_valid_mc = sim1["lula"]
margem_1t_mc = sim1["lula"] - sim1["flavio"]
_mat = np.column_stack([sim1[c_] for c_ in CANDS])
_rank = np.argsort(-_mat, axis=1)
p_1t = float((lula_valid_mc > 50).mean())
p_flavio_1t = float((sim1["flavio"] > 50).mean())
P_FLAVIO_TOP2_VOTOS = float(((_rank[:, 0] == 1) | (_rank[:, 1] == 1)).mean())
p_lula_lidera_1t = float((margem_1t_mc > 0).mean())
# 3º lugar entre candidatos (o agregado "outros" soma vários nomes e não concorre a posição)
_cn = [c_ for c_ in CANDS if c_ != "outros"]
_rank_c = np.argsort(-np.column_stack([sim1[c_] for c_ in _cn]), axis=1)
p_terceiro = {c_: float((_rank_c[:, 2] == i_).mean()) for i_, c_ in enumerate(_cn) if c_ not in ("lula", "flavio")}
q_1t = np.percentile(lula_valid_mc, [5, 25, 50, 75, 95])
q_margem_1t = np.percentile(margem_1t_mc, [5, 10, 25, 50, 75, 90, 95])
q_share = {c_: [round(float(v), 1) for v in np.percentile(sim1[c_], [10, 50, 90])] for c_ in CANDS}
# sensibilidade do 1º turno ao viés (0 / prior / histórico cheio)
_r_sens = np.random.default_rng(43)
def _p1t_sens(bm):
# troca o viés sorteado por outro com média alternativa e a mesma incerteza (a linha "prior" reproduz a manchete)
mm = margem_1t_mc + b1 - (bm + BIAS_1T_SD*_t_unit(_r_sens, M1) if bm else 0.0)
return {"p_lula_lidera": round(float((mm > 0).mean()), 3), "margem_mediana": round(float(np.median(mm)), 1)}
sens_1t = {"sem viés": _p1t_sens(0.0), f"prior ({BIAS_1T_MEAN:+.1f})": _p1t_sens(BIAS_1T_MEAN), f"histórico cheio ({BIAS_1T_HIST:+.1f})": _p1t_sens(BIAS_1T_HIST)}
print(f"[1º turno] {len(p1)} pesquisas | válidos hoje: " + ", ".join(f"{CAND_LABEL[c_]} {share_now[c_]:.1f}" for c_ in CANDS)
+ f" | viés prior {BIAS_1T_MEAN:+.1f}±{BIAS_1T_SD} | P(Lula>50%)={p_1t:.3f} P(Flávio top-2)={P_FLAVIO_TOP2_VOTOS:.3f} P(Lula lidera)={p_lula_lidera_1t:.3f}")
def estrutural_2t(L=agg1["L"], F=agg1["F"], terceiros=None, indec=agg1["indec"]+agg1["branco"],
mult_lula=1.0, mult_flavio=1.0, split_indec=0.5, decis_indec=0.55, abst_dif=0.0):
terceiros = terceiros or {k_: agg1[k_] for k_ in transf}
l, f = L, F
for k_, share in terceiros.items():
pf, pl = transf[k_]
l += share * min(pl*mult_lula, 1-pf*mult_flavio)
f += share * pf*mult_flavio
pool = indec * decis_indec
l += pool*split_indec; f += pool*(1-split_indec)
l -= abst_dif
return 100*(l-f)/(l+f), l, f
# Nível estrutural pós-1º turno: resultado oficial + transferência. Nos primeiros dias depois de 4/out é a melhor informação que
# existe (voto contado, sem erro de pesquisa); é combinado ao nível das pesquisas por precisão e perde peso sozinho conforme as
# pesquisas pós-1º turno se acumulam. O desvio cobre a incerteza da transferência declarada e da mudança de comparecimento.
# É uma foto de 4/out: como medida do nível de HOJE, à incerteza da transferência soma-se a deriva do eleitorado desde o 1º turno
# (SIG_ENTRE_DIA por dia). Sem essa parcela o peso do voto contado ficava preso em ~50% independentemente de quantas pesquisas
# novas houvesse (auditoria de 18/out), ao contrário do que o parágrafo acima promete e do que o MCMC faz (observação no dia 5/out).
SD_ESTRUT = 2.5
SD_ESTRUT_HOJE = SD_ESTRUT
NIVEL_ESTRUT = float(estrutural_2t()[0]) if POS_1T else None
W_ESTRUT = 0.0
level_pesq, se_pesq = level_final, se_final
# Fração do nível de hoje que vem de pesquisas de 2º turno feitas DEPOIS do 1º turno (revisão de 4/out, com o resultado na mão).
# Depois da quebra de SD_QUEBRA_1T no passeio, o filtro pesa as ondas novas por precisão contra o nível antigo com variância
# P + 6²; essa é a mesma conta, feita fora do filtro, para saber qual desconto de viés vale para o nível.
F_POS1T = 0.0
if POS_1T:
_wpos = float(df.loc[df.campo_fim > T1, "w_precisao"].sum())
F_POS1T = _wpos / (_wpos + 1.0/(se_pesq**2 + SD_QUEBRA_1T**2)) if _wpos > 0 else 0.0
N_POS1T = int((df.campo_fim > T1).sum()) if POS_1T else 0
RISCO_CANDIDATURA = 0.0 if HOJE >= T2 else 0.01 # com as urnas abertas não há mais substituição de candidato
P_FLAVIO_2T_MERC = 0.88 # referência histórica (v24): mercado "Flávio em 2º lugar"
P_FLAVIO_2T = ((1.0 if FLAVIO_TOP2_REAL else 0.0) if POS_1T else P_FLAVIO_TOP2_VOTOS) * (1 - RISCO_CANDIDATURA)
# ----------------------------------------------------------------------------
# 4. MONTE CARLO — 2º TURNO
# ----------------------------------------------------------------------------
N = 400_000
dias = max((T2 - HOJE).days, 0)
dias_ate_t1, dias_t1_t2 = max((T1 - HOJE).days, 0), max((T2 - max(T1, HOJE)).days, 0)
# Deriva em duas fases: até 4/out, σ_rw estimado; entre turnos, σ calibrado no movimento observado em 2014/18/22 (descontado o
# erro de pesquisa) e encolhido 50% para σ_rw, porque são só três ciclos.
SIG_ENTRE_DIA = math.sqrt(0.5*SIG_ENTRE_DIA_BRUTO**2 + 0.5*SIG_RW_KF**2)
SD_DIARIO = SIG_RW_KF
MULT_ENTRE_TURNOS = round(SIG_ENTRE_DIA / SIG_RW_KF, 2)
sd_deriva = math.sqrt(dias_ate_t1*SIG_RW_KF**2 + dias_t1_t2*SIG_ENTRE_DIA**2)
sd_deriva_antigo = 0.55*math.sqrt(max(dias, 0))
# Média da deriva (só na variante com momentum): metade do agregado das diferenças intra-instituto, em p.p./dia, vezes os dias que faltam.
MU_DERIVA, SD_MU_DERIVA, MOM = 0.0, 0.0, None
if MOMENTUM and POS_1T:
import mudanca_2turno
MOM = mudanca_2turno.resumo(DATA, hoje=HOJE)
if MOM:
MU_DERIVA = ENCOLHE_MOMENTUM * MOM["m_dia"] * dias_t1_t2
SD_MU_DERIVA = ENCOLHE_MOMENTUM * max(MOM["se_dia"], SD_MOMENTUM_PISO) * dias_t1_t2 # o encolhimento vale para o erro também
BIAS_APLICADO = BIAS_MEAN
BIAS_SD_FINAL = BIAS_SD
# Antes do 1º turno, as pesquisas de 2º turno ainda não passaram pelo resultado do 1º turno. O desconto de BIAS_MEAN vem da rodada
# final (véspera do 2º turno) e subestima o erro das rodadas feitas antes do 1º turno: sem ajuste, o 2º turno partia das pesquisas
# de hoje com desconto de ~1 ponto, enquanto o 1º turno, com as mesmas pesquisas, recebia ~3,5, e as duas previsões não
# conversavam (a tabela "1º turno → 2º turno" partia de outra margem). Regra até 4/out: o 2º turno recebe o mesmo desconto do
# 1º turno (BIAS_1T_MEAN ± BIAS_1T_SD), porque um erro na margem do 1º turno se transmite quase um para um ao ponto de partida do
# 2º (inclinação da tabela 1º → 2º turno ≈ 1). O histórico das rodadas de 2º turno publicadas antes do 1º turno
# (vies_2t_pre_1turno.csv: 2014, 2018, 2022) tem erros ainda maiores (+3 a +16); ele fica como referência, não como regra,
# porque 2022 sozinho domina a média e porque em 2026 a distância entre 1º e 2º turno nas pesquisas já é pequena.
BIAS_PRE1T_HIST, BIAS_PRE1T_CICLOS = None, {}
_pre_path = DATA + "vies_2t_pre_1turno.csv"
if os.path.exists(_pre_path):
_pre = pd.read_csv(_pre_path)
BIAS_PRE1T_CICLOS = _pre.groupby("ciclo").erro_margem.mean().to_dict()
_pp = {c_: 0.5 ** ((2022 - c_) / MEIA_VIDA_CICLOS) for c_ in BIAS_PRE1T_CICLOS}
BIAS_PRE1T_HIST = round(sum(BIAS_PRE1T_CICLOS[c_]*_pp[c_] for c_ in BIAS_PRE1T_CICLOS) / sum(_pp.values()), 2)
BIAS_PRE1T_MEAN, BIAS_PRE1T_SD = BIAS_1T_MEAN, round(max(BIAS_SD, BIAS_1T_SD), 2)
# Auditoria de 3/out: a troca de regime dependia só da data (em 4/out o desconto caía de +3,5 para +0,95 sem resultado nem
# pesquisa nova). Agora depende do resultado: enquanto resultado_1turno.csv estiver vazio vale o desconto pré-1º turno. Com o
# resultado, o desconto das pesquisas de 2º turno é o histórico das rodadas finais mais uma fração do erro COMUM observado no 1º
# turno (nos três ciclos com dados, o erro do 2º turno acompanhou o do 1º com inclinação ≈ 0,25), e só incide sobre a parcela
# do nível que vem de pesquisas (a parcela estrutural vem de voto contado).
# Revisão de 22/out (v56): RHO_1T_2T passou de 0,25 para 0. O erro de 2026 no 1º turno teve a assinatura de voto útil: a média
# final acertou Lula (45,8 contra 45,16) e errou Flávio (43,1 contra 47,03) pelo que os demais perderam (11,0 contra 7,8). Esse
# mecanismo não existe num 2º turno com dois nomes; o erro próprio de 2º turno segue no desconto das rodadas finais (BIAS_MEAN).
# Com 0,25, o 2º turno herdava ~0,9 ponto (1,19 × a parcela de pesquisas) e a chance de Lula era 12,8% (ensemble 15,0%) em 22/out.
RHO_1T_2T, BIAS_POS1T_SD = 0.0, 2.5
# Revisão de 4/out (com o resultado): a regra acima vale para pesquisas feitas DEPOIS do 1º turno. As ondas de 2º turno feitas
# antes dele são as mesmas que, no 1º turno, erraram a margem em ERRO_COMUM_1T; antes do resultado elas recebiam o desconto
# suposto do 1º turno (BIAS_PRE1T_MEAN), e agora recebem o erro observado. Sem isso, na manhã de 5/out, sem nenhuma pesquisa nova,
# o desconto caía de +3,4 para +2,0 e a chance de Lula subia justamente no dia em que o resultado mostrou o erro contra ele.
# O desconto do nível é a média dos dois, pesada pela fração do nível que vem de ondas pós-1º turno (F_POS1T).
# A combinação com o nível estrutural (voto contado) usa o erro TOTAL do nível das pesquisas (amostral + sistemático): antes
# usava só o amostral (± 0,7 contra ± 2,5) e o resultado oficial pesava 8%.
BIAS_PRE1T_OBS = BIAS_POS_REGRA = None
if not POS_1T:
BIAS_APLICADO, BIAS_SD = BIAS_PRE1T_MEAN, BIAS_PRE1T_SD
else:
BIAS_PRE1T_OBS = float(ERRO_COMUM_1T if ERRO_COMUM_1T is not None else BIAS_PRE1T_MEAN)
BIAS_POS_REGRA = BIAS_MEAN + RHO_1T_2T*(ERRO_COMUM_1T or 0.0)
_b_pesq = F_POS1T*BIAS_POS_REGRA + (1 - F_POS1T)*BIAS_PRE1T_OBS
_var_pesq = se_pesq**2 + BIAS_POS1T_SD**2
_dias_desde_t1 = max((HOJE - T1).days, 0)
SD_ESTRUT_HOJE = math.sqrt(SD_ESTRUT**2 + _dias_desde_t1 * SIG_ENTRE_DIA**2) # foto de 4/out + deriva até hoje
W_ESTRUT = float(_var_pesq / (_var_pesq + SD_ESTRUT_HOJE**2))
level_final = (1 - W_ESTRUT)*level_pesq + W_ESTRUT*NIVEL_ESTRUT
se_final = math.sqrt((1 - W_ESTRUT)**2*se_pesq**2 + W_ESTRUT**2*SD_ESTRUT_HOJE**2)
BIAS_APLICADO = round((1 - W_ESTRUT)*_b_pesq, 2)
BIAS_SD = round((1 - W_ESTRUT)*BIAS_POS1T_SD, 2)
if SO_POS1T and not POS1T_COM_ESTRUT:
# Variante: nível = só as pesquisas pós-1º turno, sem parcela de voto contado; todo o desconto é a regra pós-1º turno.
F_POS1T, W_ESTRUT = 1.0, 0.0
level_final, se_final = level_pesq, se_pesq
BIAS_APLICADO, BIAS_SD = round(BIAS_POS_REGRA, 2), round(BIAS_POS1T_SD, 2)
SIG_REGIAO = 2.5
if REGIAO_UF:
PESO_REGIAO = {r_: sum(PESO_UF[u_] for u_ in PESO_UF if REGIAO_UF[u_] == r_) for r_ in sorted(set(REGIAO_UF.values()))}
else:
PESO_REGIAO = {"N": 0.085, "NE": 0.265, "CO": 0.075, "SE": 0.43, "S": 0.145}
# Choques regionais: retirados da simulação na auditoria de 3/out (ver simular); as constantes ficam só para o histórico do painel.
SD_RUIDO = math.sqrt(sum((SIG_REGIAO*w_)**2 for w_ in PESO_REGIAO.values()))
def simular(level, bias_mean, n=N, seed=1, se_level=None, sdd=None, pflav=None, bias_sd=None):
r = np.random.default_rng(seed)
# Deriva e erro sistemático com caudas t de Student (mesmo desvio da versão normal). O sorteio de choques por macrorregião foi
# retirado: somava normais sem alterar nenhuma saída nacional (o erro total já era BIAS_SD).
est = r.normal(level, se_final if se_level is None else se_level, n)
deriva = (sd_deriva if sdd is None else sdd) * _t_unit(r, n)
if SD_MU_DERIVA > 0: # variante com momentum: a deriva tem média (incerta); fora dela nada é sorteado e o fluxo aleatório não muda
deriva = deriva + r.normal(MU_DERIVA, SD_MU_DERIVA, n)
_bsd = BIAS_SD if bias_sd is None else bias_sd
vies = bias_mean + _bsd * _t_unit(r, n)
adv = r.random(n) < (P_FLAVIO_2T if pflav is None else pflav)
est_outro = r.normal(7.0, 4.0, n)
m = np.where(adv, est, est_outro) + deriva - vies
return m, adv
margem_final, adv_flavio = simular(level_final, BIAS_APLICADO)
p_lula = float((margem_final > 0).mean())
p_lula_vs_flavio = float((margem_final[adv_flavio] > 0).mean())
p_empate = float((np.abs(margem_final) < 1.5).mean())
q = np.percentile(margem_final, [5, 10, 25, 50, 75, 90, 95])
var_parts = {"Agregado hoje (amostral+casas)": se_final**2, "Deriva até 25/out": sd_deriva**2,
"Erro sistemático das pesquisas": BIAS_SD**2}
tot_var = sum(var_parts.values())
var_share = {k_: v/tot_var for k_, v in var_parts.items()}
sens = {}
for lab_l, lv in [("nowcast combinado", level_final), ("média ponderada últimas", media_simples_adj)]:
for lab_b, bm in [("viés recalibrado", BIAS_APLICADO), ("sem correção de viés", 0.0)]:
m_, _ = simular(lv, bm, n=200_000, seed=7)
sens[f"{lab_l} | {lab_b}"] = round(float((m_ > 0).mean()), 3)
sens_bias = {f"{bm:+.1f}": round(float((simular(level_final, bm, n=200_000, seed=7)[0] > 0).mean()), 3) for bm in sorted({0.0, BIAS_MEAN, BIAS_APLICADO, 2.1})}
# "O que mudou no modelo (v3)": parte do resultado do modelo anterior (v2, valores do último resultados.json publicado) e liga um
# fator por vez. O nível "v2" é recomposto com a identificação antiga (média de todos os institutos, peso qualidade × nº de ondas).
_wq_all = {i_: peso_qualidade(i_) * int((jan.instituto == i_).sum()) for i_ in jan.instituto.unique()}
_shift_v2 = sum(fit_win["house"][i_]*_wq_all[i_] for i_ in _wq_all) / sum(_wq_all.values()) # média dos efeitos de casa sob a âncora (v2 forçava = 0)
level_v2_like = level_nac + _shift_v2
def _p_cfg(level, se_l, bias, bsd, sdd, pflav):
m_, _ = simular(level, bias, n=200_000, seed=11, se_level=se_l, sdd=sdd, pflav=pflav, bias_sd=bsd)
return round(float((m_ > 0).mean()), 3)
efeitos_fatores = {"Modelo anterior (v2): âncora em todos os institutos, viés +2,1, deriva 4,3": _p_cfg(level_v2_like, se_nac, 2.1, 2.0, 4.3, P_FLAVIO_2T_MERC)}
efeitos_fatores["+ âncora nos institutos calibrados (identificação dos efeitos de casa)"] = _p_cfg(level_nac, se_nac, 2.1, 2.0, 4.3, P_FLAVIO_2T_MERC)
efeitos_fatores[f"+ viés recalibrado ({BIAS_MEAN:+.1f} ± {BIAS_SD_FINAL})"] = _p_cfg(level_nac, se_nac, BIAS_MEAN, BIAS_SD_FINAL, 4.3, P_FLAVIO_2T_MERC)
efeitos_fatores[f"+ deriva em duas fases calibrada ({sd_deriva:.1f})"] = _p_cfg(level_nac, se_nac, BIAS_MEAN, BIAS_SD_FINAL, sd_deriva, P_FLAVIO_2T_MERC)
efeitos_fatores[f"+ P(Flávio no 2º turno) do modelo do 1º turno ({P_FLAVIO_2T:.2f})"] = _p_cfg(level_nac, se_nac, BIAS_MEAN, BIAS_SD_FINAL, sd_deriva, P_FLAVIO_2T)
if estadual["usado"]:
efeitos_fatores["+ pesquisas estaduais"] = _p_cfg(level_final, se_final, BIAS_MEAN, BIAS_SD_FINAL, sd_deriva, P_FLAVIO_2T)
if BIAS_APLICADO != BIAS_MEAN:
efeitos_fatores[f"+ viés das pesquisas de 2º turno anteriores ao 1º turno ({BIAS_APLICADO:+.1f} ± {BIAS_SD})"] = _p_cfg(level_final, se_final, BIAS_APLICADO, BIAS_SD, sd_deriva, P_FLAVIO_2T)
# ENSEMBLE — 75% modelo / 25% mercados / 0% fundamentos quando há preço de mercado válido; senão 100% modelo (a versão até 3/out usava
# 65/25/10 antes do 1º turno)
MERC = json.load(open(DATA + "mercados.json", encoding="utf-8")) # leitura do mercado (valor, data, volume): fonte única para modelo e painel
P_MERC, SD_ALT = float(MERC["p_lula"]), 4.5
# Fundamentos: ótimo+bom do governo no Datafolha durante a campanha, antes do 1º turno (série homogênea; a versão anterior
# misturava a aprovação pessoal de Lula na CNT/Sensus de 2008, 77,7, com outras medidas). Margem = 2º turno do incumbente.
FUND_HIST = [(2006, 47.0, 21.66), (2014, 39.0, 3.28), (2022, 31.0, -1.80)]
FUND_MEDIDA = "Datafolha, durante a campanha"
APROVACAO_ATUAL = {"Datafolha 22–23/set": 34} # mesma medida da série histórica (ótimo + bom)
aprovacao_media = float(np.mean(list(APROVACAO_ATUAL.values())))
_fx = np.array([a_ for _, a_, _ in FUND_HIST]); _fy = np.array([m_ for _, _, m_ in FUND_HIST])
FUND_B, FUND_A = np.polyfit(_fx, _fy, 1)
FUND_EMPATE = -FUND_A/FUND_B
mu_fund = float(FUND_A + FUND_B*aprovacao_media)
P_FUND = float(norm.cdf(mu_fund/SD_ALT))
mu_merc = SD_ALT * norm.ppf(P_MERC)
# Pesos (auditoria de 3/out). Fundamentos: reta com três eleições e desvio preditivo real acima de 8 pontos — a componente dizia
# ~50% e não informava; fica calculada para exibição, com peso 0. Mercado: só entra se o preço for recente (até 5 dias) e, no
# regime pós-1º turno, posterior ao resultado; um preço velho com 25% do peso contaminaria o resultado sem aviso. Um preço lido
# na própria noite de 4/out, depois da apuração, vale se mercados.json trouxer "pos_resultado": true.
_md = pd.Timestamp(MERC["data_iso"]) if MERC.get("data_iso") else None
MERC_VALIDO = bool(_md is not None and (HOJE - _md).days <= 5 and not (POS_1T and _md < T1)
and not (POS_1T and _md == T1 and not MERC.get("pos_resultado")))
W_ENS = {"modelo": 0.75, "mercados": 0.25, "fundamentos": 0.0} if MERC_VALIDO else {"modelo": 1.0, "mercados": 0.0, "fundamentos": 0.0}
r2 = np.random.default_rng(99)
comp = r2.random(N)
ens = np.where(comp < W_ENS["modelo"], margem_final,
np.where(comp < W_ENS["modelo"] + W_ENS["mercados"], r2.normal(mu_merc, SD_ALT, N), r2.normal(mu_fund, SD_ALT, N)))
p_ens = float((ens > 0).mean())
cen = {"Lula por mais de 4 p.p.": float((ens > 4).mean()), "Lula por 0 a 4 p.p.": float(((ens > 0) & (ens <= 4)).mean()),
"Flávio por 0 a 4 p.p.": float(((ens <= 0) & (ens > -4)).mean()), "Flávio por mais de 4 p.p.": float((ens <= -4).mean())}
p_ens_empate = float((np.abs(ens) < 1.5).mean())
q_ens = np.percentile(ens, [10, 50, 90])
def _saida_variante(variante):
"""Saída das variantes do herói: só o que o herói e a metodologia precisam."""
return {
"data_base": str(HOJE.date()), "atualizado_em": pd.Timestamp.now(tz="America/Sao_Paulo").strftime("%Y-%m-%dT%H:%M"), "variante": variante,
"regime_pos_1t": POS_1T, "n_pesquisas": int(len(df)), "n_institutos": int(df.inst_base.nunique()), "n_series_casa": len(insts),
"primeira_pesquisa": str(df.campo_fim.min().date()), "ultima_pesquisa": str(df.campo_fim.max().date()),
"ancora_presente": sorted(set(ANCORA) & set(df.instituto)), "n_ondas_ancora": int(df.instituto.isin(ANCORA).sum()),
"nivel_ajustado_hoje_margem_valid": round(level_final, 2), "se_total": round(se_final, 2),
"nivel_pesquisas": round(level_pesq, 2), "se_pesquisas": round(se_pesq, 2), "var_ancora": round(fit_win["var_ancora"], 3),
"nivel_estrutural": round(NIVEL_ESTRUT, 2), "sd_estrutural": round(SD_ESTRUT_HOJE, 2), "peso_estrutural": round(W_ESTRUT, 3), "f_pos1t": round(F_POS1T, 3), "lambda_casa_pos1t": None,
"bias_aplicado": round(BIAS_APLICADO, 2), "bias_sd_aplicado": round(BIAS_SD, 2), "bias_pos_regra": round(BIAS_POS_REGRA, 2),
"sigma_passeio_estimado": round(SIG_RW_KF, 3), "sigma_passeio_ml": round(SIG_RW_ML, 3), "sd_deriva": round(sd_deriva, 2), "sig_entre_turnos_dia": round(SIG_ENTRE_DIA, 3),
"house_effects": {i: round(house[i], 2) for i in insts}, "sigma_inst": {i: round(sigma_inst[i], 2) for i in insts},
"media_ponderada_ultimas_ajustada": round(media_simples_adj, 2),
"p_lula": round(p_lula, 3), "p_lula_vs_flavio": round(p_lula_vs_flavio, 3), "p_empate_1p5": round(p_empate, 3), "sensibilidade": sens,
"quantis_margem_final": {str(p): round(float(v), 1) for p, v in zip([5, 10, 25, 50, 75, 90, 95], q)},
"p_ensemble": round(p_ens, 3), "p_ensemble_empate": round(p_ens_empate, 3), "pesos_ensemble": W_ENS, "mercado_valido": MERC_VALIDO, "p_mercados": P_MERC,
"cenarios_ensemble": {k_: round(v, 3) for k_, v in cen.items()}, "q_ensemble_10_50_90": [round(float(v), 1) for v in q_ens],
"mu_deriva": round(MU_DERIVA, 2), "sd_mu_deriva": round(SD_MU_DERIVA, 2),
}
if SO_POS1T:
# termina aqui para não tocar nenhum arquivo do modelo principal.
res_pos = _saida_variante("pos1t_estrut" if POS1T_COM_ESTRUT else "so_pos1t")
json.dump(res_pos, open(OUT + ("resultados_pos1t_estrut.json" if POS1T_COM_ESTRUT else "resultados_pos1t.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(f"[Pós-1º turno{' + voto contado' if POS1T_COM_ESTRUT else ''}] {len(df)} pesquisas de {res_pos['n_institutos']} institutos desde {df.campo_fim.min():%d/%m} | nível {level_final:+.2f} ± {se_final:.2f} "
f"(estrutural {NIVEL_ESTRUT:+.2f}, peso {W_ESTRUT:.0%}) | viés {BIAS_APLICADO:+.2f} ± {BIAS_SD} | σ_rw {SIG_RW_KF:.3f} | P(Lula)={p_lula:.3f} ensemble {p_ens:.3f}")
sys.exit(0)
def p_dado_lead(lead):
sd = math.sqrt(BIAS_SD**2 + 0.8**2)
return 1 - norm.cdf(0, loc=lead - BIAS_APLICADO, scale=sd)
leads = np.arange(-2, 9, 1)
tabela_lead = {int(l): round(p_dado_lead(l), 3) for l in leads}
lead_breakeven = BIAS_APLICADO
lead_70 = BIAS_APLICADO + norm.ppf(0.70) * math.sqrt(BIAS_SD**2 + 0.64)
lead_90 = BIAS_APLICADO + norm.ppf(0.90) * math.sqrt(BIAS_SD**2 + 0.64)
# ----------------------------------------------------------------------------
# 4c. MODELO PARALELO POR MCMC (mcmc_2turno.py) — mesmas pesquisas da janela, mesmos priors e as mesmas peças do Monte Carlo acima
# (efeitos de casa ancorados, prior do erro de cada instituto no 1º turno, quebra de 4/out, nível estrutural, desconto de viés por
# regime com caudas t, deriva em duas fases), mas com o posterior completo: σ_rw, σ_entre, σ_p, efeitos de casa, viés e trajetória
# sorteados em conjunto, em vez de fixados em estimativas pontuais. Não altera nenhuma saída do modelo principal (manchete).
# ----------------------------------------------------------------------------
import time as _time
import mcmc_2turno
_t0_mc = _time.time()
_ult = max(t_now, dia(T1) + 1) if POS_1T else t_now # último dia do estado: inclui o dia seguinte ao 1º turno (voto contado)
_mu_obs = (np.where(jan.campo_fim > T1, BIAS_POS_REGRA, BIAS_PRE1T_OBS) if POS_1T else np.full(len(jan), BIAS_PRE1T_MEAN))
_extras = []
if POS_1T:
_extras.append({"dia": dia(T1) + 1 - d0_jan, "valor": NIVEL_ESTRUT, "sd": SD_ESTRUT, "com_vies": False})
if estadual["usado"]:
_extras.append({"dia": t_now - d0_jan, "valor": estadual["nivel"], "sd": estadual["se"], "com_vies": True,
"mu": BIAS_POS_REGRA if POS_1T else BIAS_PRE1T_MEAN})
_fut = np.arange(_ult + 1, dia(T2) + 1)
MC = mcmc_2turno.amostrar(jan.margem_valid.values, (jan.dia - d0_jan).values, jan.se2.values, jan.instituto.values, _ult - d0_jan + 1,
set(ANCORA), Q_ANCORA, mu_obs=_mu_obs, sd_vies=(BIAS_POS1T_SD if POS_1T else BIAS_PRE1T_SD),
prior_h=prior_h_2t, tau_h=tau_prior_h or 1.5, dia_t1=dia(T1) - d0_jan,
sd_quebra=(SD_QUEBRA_1T if POS_1T else 0.0), extras=_extras,
prior_entre=(math.log(SIG_ENTRE_DIA), 0.5), n_fut_rw=int((_fut <= dia(T1)).sum()),
n_fut_entre=int((_fut > dia(T1)).sum()), dia_hoje=t_now - d0_jan, a_h=jan.a_casa.values,
prior_mu_der=(MU_DERIVA, SD_MU_DERIVA))
_S = MC["amostras"]
margem_mcmc = _S["x_final"].ravel()
_rm = np.random.default_rng(5)
_adv_m = _rm.random(len(margem_mcmc)) < P_FLAVIO_2T
margem_mcmc = np.where(_adv_m, margem_mcmc, _rm.normal(7.0, 4.0, len(margem_mcmc)))
p_lula_mcmc = float((margem_mcmc > 0).mean())
q_mcmc = np.percentile(margem_mcmc, [5, 10, 25, 50, 75, 90, 95])
_comp_m = _rm.random(len(margem_mcmc))
ens_mcmc = np.where(_comp_m < W_ENS["modelo"], margem_mcmc,
np.where(_comp_m < W_ENS["modelo"] + W_ENS["mercados"], _rm.normal(mu_merc, SD_ALT, len(margem_mcmc)),
_rm.normal(mu_fund, SD_ALT, len(margem_mcmc))))
def _qs(a_, ps=(10, 50, 90)): return [round(float(v), 2) for v in np.percentile(a_, ps)]
_xh = _S["x_hoje"].ravel()
# variância da margem final do MCMC decomposta como na seção 4 (nível de hoje, deriva até 25/out; o viés já está dentro do nível)
_var_der = float(_S["deriva"].var()); _var_niv = float(_xh.var())
MCMC_CONVERGIU = MC["rhat_max"] < 1.05
mcmc_res = {
"p_lula": round(p_lula_mcmc, 3), "p_lula_vs_flavio": round(float((margem_mcmc[_adv_m] > 0).mean()), 3),
"p_empate_1p5": round(float((np.abs(margem_mcmc) < 1.5).mean()), 3),
"p_ensemble": round(float((ens_mcmc > 0).mean()), 3), "q_ensemble_10_50_90": _qs(ens_mcmc),
"quantis_margem_final": {str(p): round(float(v), 1) for p, v in zip([5, 10, 25, 50, 75, 90, 95], q_mcmc)},
"nivel_hoje": round(float(_xh.mean()), 2), "se_nivel_hoje": round(float(_xh.std()), 2),
"nivel_hoje_q": _qs(_xh), "sd_deriva": round(float(_S["deriva"].std()), 2),
"var_share": {"Nível de hoje (pesquisas, casas, viés, voto contado)": round(_var_niv/(_var_niv + _var_der), 3),
"Deriva até 25/out": round(_var_der/(_var_niv + _var_der), 3)},
"sig_rw": _qs(_S["sig_rw"]), "sig_entre": _qs(_S["sig_entre"]), "beta": _qs(_S["beta"]), "m_sigma_inst": _qs(_S["m_sigp"]),
"house_effects": {p_: _qs(_S["h"][:, :, j_]) for j_, p_ in enumerate(MC["insts"])},
"sigma_inst": {p_: _qs(_S["sig_p"][:, :, j_]) for j_, p_ in enumerate(MC["insts"])},
"trajetoria": {"inicio": str((T0 + pd.Timedelta(days=d0_jan)).date()),
"q10_50_90": [[round(float(v), 2) for v in col] for col in np.percentile(_S["x"].reshape(-1, _S["x"].shape[-1]), [10, 50, 90], axis=0).T]},
"desconto_obs": {"antes_1t": round(float(_mu_obs[(jan.campo_fim <= T1).values].mean()), 2) if (jan.campo_fim <= T1).any() else None,
"depois_1t": (round(float(BIAS_POS_REGRA), 2) if POS_1T else None), "sd_vies": BIAS_POS1T_SD if POS_1T else BIAS_PRE1T_SD},
"obs_extras": [{k_: (round(v, 2) if isinstance(v, float) else v) for k_, v in e_.items()} for e_ in _extras],
"rhat_max": round(MC["rhat_max"], 3), "ess_min": MC["ess_min"], "convergiu": MCMC_CONVERGIU,
"diagnostico": {k_: MC["diagnostico"][k_] for k_ in ["x_hoje", "x_final", "sig_rw", "sig_entre", "beta", "m_sigp"]},
"aceitacao": MC["aceitacao"], "n_cadeias": MC["n_cadeias"], "n_aquec": MC["n_aquec"], "n_amostras": MC["n_amostras"],
"sig_entre_tem_dados": MC["entre_tem_dados"], "segundos": round(_time.time() - _t0_mc, 1),
# comparação com o modelo principal: o nível dele é o das pesquisas (antes do desconto); o "verdadeiro" é nível − viés aplicado
"comparacao": {"p_lula_principal": round(p_lula, 3), "p_ensemble_principal": round(p_ens, 3),
"nivel_verdadeiro_principal": round(level_final - BIAS_APLICADO, 2),
"se_nivel_verdadeiro_principal": round(math.sqrt(se_final**2 + BIAS_SD**2), 2),
"mediana_principal": round(float(q[3]), 1), "sd_deriva_principal": round(sd_deriva, 2),
"sig_rw_principal": round(SIG_RW_KF, 3), "sig_entre_principal": round(SIG_ENTRE_DIA, 3)},
}
print(f"[MCMC] {MC['n_cadeias']}×{MC['n_amostras']} sorteios | hoje {mcmc_res['nivel_hoje']:+.2f} ± {mcmc_res['se_nivel_hoje']:.2f} | "
f"σ_rw {mcmc_res['sig_rw'][1]:.3f} | P(Lula)={p_lula_mcmc:.3f} (principal {p_lula:.3f}) | ensemble {mcmc_res['p_ensemble']:.3f} "
f"(principal {p_ens:.3f}) | R̂ máx {MC['rhat_max']:.3f} ESS mín {MC['ess_min']} | {mcmc_res['segundos']} s")
if not MCMC_CONVERGIU:
print(f"[AVISO] MCMC sem convergência (R̂ máx {MC['rhat_max']:.3f} > 1,05): resultado do modelo paralelo não confiável.")
if MOMENTUM:
# Variante com momentum: sai depois do MCMC (que recebeu a mesma prior de deriva), sem tocar nenhum arquivo do modelo principal.
res_mom = _saida_variante("momentum")
res_mom["momentum"] = ({k_: (round(v_, 4) if isinstance(v_, float) else v_) for k_, v_ in MOM.items() if k_ != "lento"} if MOM else None)
res_mom["encolhimento_momentum"] = ENCOLHE_MOMENTUM; res_mom["sd_momentum_piso_dia"] = SD_MOMENTUM_PISO
res_mom["mcmc"] = {k_: mcmc_res[k_] for k_ in ["p_lula", "p_ensemble", "quantis_margem_final", "q_ensemble_10_50_90", "nivel_hoje", "se_nivel_hoje",
"sd_deriva", "rhat_max", "ess_min", "convergiu", "segundos"]}
res_mom["mcmc"]["mu_der"] = _qs(_S["mu_der"]) if "mu_der" in _S else None
json.dump(res_mom, open(OUT + "resultados_momentum.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(f"[Momentum] deriva com média {MU_DERIVA:+.2f} ± {SD_MU_DERIVA:.2f} (agregado {MOM['taxa']:+.2f} ± {MOM['se_taxa']:.2f} p.p./semana, {MOM['n_dif']} diferenças de {MOM['n_inst']} séries, encolhido {ENCOLHE_MOMENTUM:.0%})"
if MOM else "[Momentum] sem diferenças suficientes: deriva com média zero")
print(f"[Momentum] nível {level_final:+.2f} ± {se_final:.2f} | P(Lula)={p_lula:.3f} ensemble {p_ens:.3f} | MCMC {p_lula_mcmc:.3f} ensemble {mcmc_res['p_ensemble']:.3f}")
sys.exit(0)
# ----------------------------------------------------------------------------
# 4b. ABSTENÇÃO — quanto a margem nacional muda se o comparecimento de 2026 se afastar do padrão de 2022.
# Dois canais: (i) geográfico, entre UFs: cada UF pesa no total pelo seu comparecimento, então faltar mais onde um
# candidato lidera reduz o peso dessa UF; (ii) composicional, dentro de cada UF: quem falta é mais pobre e menos
# escolarizado (abstenção de 52% entre analfabetos × 12% no superior, 1T 2022), grupos em que Lula lidera; calibrado em
# K = 0,25 p.p. de margem por p.p. de abstenção (Laureno: +4 p.p. de abstenção ≈ −1 ponto na vantagem de Lula), faixa 0–0,4.
# O padrão de 2022 já está embutido no desconto histórico (as finais são comparadas com resultados que incluem a abstenção
# daquele ano), então os cenários medem só o DESVIO em relação a 2022, sem dupla contagem.
# ----------------------------------------------------------------------------
abst = {"usado": False}
_abst_path = DATA + "abstencao_2022_uf.csv"
if ref_uf is not None and os.path.exists(_abst_path):
K_COMP, K_COMP_LO, K_COMP_HI = 0.25, 0.0, 0.40
au = ref_uf.merge(pd.read_csv(_abst_path), on="uf", how="inner").copy()
_sw = {}
if estadual.get("por_uf") is not None:
_sw = dict(zip(estadual["por_uf"].uf, estadual["por_uf"].swing))
_mu_reg = estadual.get("mu_regiao", {}); _mu_s = estadual.get("mu_swing", 0.0)
au["swing"] = [_sw.get(u_, _mu_reg.get(r_, _mu_s)) for u_, r_ in zip(au.uf, au.regiao)]
au["a0"] = au.abst_2t_2022/100
def _nac(a_vec, marg):
V_ = au.eleitorado*(1 - a_vec)
return float((V_*marg).sum()/V_.sum())
_m_raw = au.m22 + au.swing
_c = level_final - _nac(au.a0, _m_raw) # alinha o nível por UF ao placar nacional do modelo
au["m26"] = _m_raw + _c
base_nac = _nac(au.a0, au.m26)
def cenario(delta_reg=None, delta_uf=None, k=K_COMP):
d_ = np.zeros(len(au))
if delta_reg: d_ = np.array([delta_reg.get(r_, 0.0) for r_ in au.regiao])/100
if delta_uf is not None: d_ = np.asarray(delta_uf)/100
geo = _nac(au.a0 + d_, au.m26) - base_nac
V_ = au.eleitorado*(1 - au.a0)
comp = -k*100*float((V_*d_).sum()/V_.sum()) # média ponderada do aumento de abstenção × K
return geo, comp
_todas = {r_: 1.0 for r_ in au.regiao.unique()}
cen_defs = [
("Abstenção igual à de 2022 (20,6%)", {}, None),
("Sobe para cerca de 22% em todo o país (projeção de analistas)", {r_: 1.4 for r_ in _todas}, None),
("Sobe para cerca de 25% em todo o país (cenário de desinteresse)", {r_: 4.4 for r_ in _todas}, None),
("Nordeste falta mais: +3 p.p. só no NE", {"NE": 3.0}, None),
("Sul e Centro-Oeste faltam mais: +3 p.p. só em S e CO", {"S": 3.0, "CO": 3.0}, None),
("Mobilização no Nordeste: −2 p.p. só no NE", {"NE": -2.0}, None),
("Repete a variação 1º→2º turno de 2022, UF por UF", None, (au.abst_2t_2022 - au.abst_1t_2022).values),
]
_cen_out = []
for nome_, dreg_, duf_ in cen_defs:
geo_, comp_ = cenario(dreg_ or None, duf_)
_, comp_lo = cenario(dreg_ or None, duf_, K_COMP_LO)
_, comp_hi = cenario(dreg_ or None, duf_, K_COMP_HI)
dm_ = geo_ + comp_
m_, _ = simular(level_final + dm_, BIAS_APLICADO, n=200_000, seed=7)
_cen_out.append({"cenario": nome_, "geo": round(geo_, 2), "comp": round(comp_, 2), "total": round(dm_, 2),
"total_faixa": [round(geo_ + comp_lo, 2), round(geo_ + comp_hi, 2)], "p_lula": round(float((m_ > 0).mean()), 3)})
_p_base = _cen_out[0]["p_lula"]
for c_ in _cen_out: c_["dp_lula"] = round(c_["p_lula"] - _p_base, 3)
_geo_ne1, _comp_ne1 = cenario({"NE": 1.0}); _geo_u1, _comp_u1 = cenario({r_: 1.0 for r_ in _todas})
reg_tab = []
for r_, g_ in au.groupby("regiao"):
V_ = g_.eleitorado*(1 - g_.a0)
reg_tab.append({"regiao": r_, "eleitorado_pct": round(100*float(g_.eleitorado.sum()/au.eleitorado.sum()), 1),
"abst_1t_2022": round(float((g_.abst_1t_2022*g_.eleitorado).sum()/g_.eleitorado.sum()), 1),
"abst_2t_2022": round(float((g_.abst_2t_2022*g_.eleitorado).sum()/g_.eleitorado.sum()), 1),
"m22": round(float((V_*g_.m22).sum()/V_.sum()), 1), "m26": round(float((V_*g_.m26).sum()/V_.sum()), 1)})
abst = {"usado": True, "k_comp": K_COMP, "k_faixa": [K_COMP_LO, K_COMP_HI], "base_nac": round(base_nac, 2),
"check_m22": round(_nac(au.a0, au.m22), 2), "abst_base_pond": round(float((au.abst_2t_2022*au.eleitorado).sum()/au.eleitorado.sum()), 2),
"cenarios": _cen_out, "por_regiao": sorted(reg_tab, key=lambda d_: -d_["eleitorado_pct"]),
"por_pp_ne": round(_geo_ne1 + _comp_ne1, 2), "por_pp_nacional": round(_geo_u1 + _comp_u1, 2)}
print(f"[Abstenção] base {base_nac:+.2f} (check 2022 {abst['check_m22']:+.2f}) | +1 p.p. NE: {abst['por_pp_ne']:+.2f} | +1 p.p. nacional: {abst['por_pp_nacional']:+.2f}")
# ----------------------------------------------------------------------------
# 5. MODELO ESTRUTURAL DE TRANSFERÊNCIA (1º → 2º turno) E TORNADO
# ----------------------------------------------------------------------------
base_m, base_l, base_f = estrutural_2t()
tornado = []
def add(nome, lo_kwargs, hi_kwargs, obs):
lo = estrutural_2t(**lo_kwargs)[0] - base_m
hi = estrutural_2t(**hi_kwargs)[0] - base_m
tornado.append({"variavel": nome, "baixo": lo, "alto": hi, "obs": obs})
add("Aprovação do governo (−3 / +3 p.p. → base de Lula)", dict(L=agg1["L"]-2.4), dict(L=agg1["L"]+2.4),
"elasticidade ≈0,8 voto/p.p. de aprovação (Lavareda: aprovação ≈ voto no 2º turno)")
add("Transferência da 3ª via para Lula (−1/3 / +50% vs. Quaest)", dict(mult_lula=0.67), dict(mult_lula=1.5),
"hoje ~2:1 para Flávio; se Cury/Caiado dividissem 50/50, +3 p.p. de margem")
add("Transferência da 3ª via para Flávio (−20% / +15%)", dict(mult_flavio=1.15), dict(mult_flavio=0.80),
"consolidação antipetista vs. dispersão em branco/nulo")
add("Indecisos/brancos que decidem: divisão (35% / 65% Lula)", dict(split_indec=0.35), dict(split_indec=0.65),
"estoque de 10–17%; Atlas: independentes Lula 38 × Flávio 26")
if abst["usado"]:
_tots = [c_["total"] for c_ in abst["cenarios"]]
tornado.append({"variavel": "Abstenção em relação a 2022 (cenários da seção Abstenção)", "baixo": min(_tots), "alto": max(_tots),
"obs": "geográfico + perfil de quem falta (K = 0,25 p.p. por p.p.); de 25% de abstenção a mobilização no NE"})
else:
add("Abstenção diferencial (Lula −1,5 p.p. / 0)", dict(abst_dif=1.5), dict(abst_dif=0.0),
"abstenção 21%→>22%; eleitor de menor renda/NE falta mais (Ibmec)")
add("Base de Flávio (evangélicos/segurança: +2 / −2 p.p.)", dict(F=agg1["F"]+2), dict(F=agg1["F"]-2),
"evangélicos ≈30% do eleitorado; Flávio 62×29 (Datafolha)")
tornado.append({"variavel": f"Erro sistemático das pesquisas ({-(BIAS_APLICADO+BIAS_SD):.0f} / {-(BIAS_APLICADO-BIAS_SD):+.0f} p.p.)",
"baixo": -(BIAS_APLICADO + BIAS_SD), "alto": -(BIAS_APLICADO - BIAS_SD),
"obs": "medição, não causa: pesquisas finais superestimaram o PT em 3 dos 5 últimos 2º turnos"})
tornado_df = pd.DataFrame(tornado)
tornado_df["amplitude"] = (tornado_df.alto - tornado_df.baixo).abs()
tornado_df = tornado_df.sort_values("amplitude", ascending=False)
# ----------------------------------------------------------------------------
# 6. O QUE O 1º TURNO DIRÁ SOBRE O 2º — margem inicial do 2º turno implicada por cada resultado do 1º (transferência declarada)
# ----------------------------------------------------------------------------
def margem_2t_dado_1t(margem_1t):
"""Desloca Lula e Flávio simetricamente até a margem observada e aplica a transferência da 3ª via + indecisos."""
d_ = (margem_1t - (share_now["lula"] - share_now["flavio"])) / 2
L_, F_ = agg1["L"] + d_*_resto/100, agg1["F"] - d_*_resto/100
return estrutural_2t(L=L_, F=F_)[0]
# A tabela parte do resultado oficial do 1º turno (não de pesquisa), então não leva erro de medição: a incerteza é a deriva entre
# os turnos e a da própria transferência declarada (2ª opção), da ordem da amplitude das variáveis de transferência no tornado.
SD_TRANSF = 2.0
_sd_t12 = math.sqrt(max(dias_t1_t2, 0)*SIG_ENTRE_DIA**2 + SD_TRANSF**2)
tabela_1t_2t = [{"margem_1t": m_, "margem_2t_inicial": round(margem_2t_dado_1t(m_), 1),
"p_lula_2t": round(float(1 - norm.cdf(0, loc=margem_2t_dado_1t(m_), scale=_sd_t12)), 3)}
for m_ in [-6, -4, -2, 0, 2, 4, 6, 8, 10]]
hist_1t = [("2006", "Lula", 56.0, 48.6, "Datafolha 12/set: 50% totais (≈56% válidos)"),
("2010", "Dilma", 55.0, 46.9, "Datafolha 21–22/set: 51% totais (≈55% válidos)"),
("2014", "Dilma", 46.0, 41.6, "Datafolha 29–30/set: 40% totais (≈46% válidos)"),
("2018", "Bolsonaro", 33.0, 46.0, "Datafolha 26–28/set: 28% totais (≈33% válidos) — onda antipetista tardia"),
("2022", "Lula", 50.0, 48.4, "Datafolha 1/out: 50% válidos")]
# ----------------------------------------------------------------------------
# 6a. INFLUÊNCIA — quanto cada pesquisa dos últimos 7 dias desloca o nível de hoje (reajuste sem ela). Em séries que terminam hoje, a
# última pesquisa pesa muito; a tabela mostra de onde vem cada movimento do placar.
# ----------------------------------------------------------------------------
_inf = {}
_corte = HOJE - pd.Timedelta(days=7)
for i_ in jan.index[jan.campo_fim > _corte]:
f_ = _ajusta_janela(jan.drop(i_))
_inf[(jan.instituto[i_], str(jan.campo_fim[i_].date()))] = {"d_nivel_2t": round(level_nac - float(f_["x"][-1]), 2)}
if not POS_1T:
for i_ in p1.index[p1.campo_fim > _corte]:
f_ = _fit_par("margem_v", _se2_m1, 1.5, 0.8, p1.drop(i_))
_inf.setdefault((p1.instituto[i_], str(p1.campo_fim[i_].date())), {})["d_margem_1t"] = round(_m1 - float(f_["x"][-1]), 2)
influencia = sorted([{"instituto": k_[0], "campo_fim": k_[1], **v} for k_, v in _inf.items()],
key=lambda d_: -max(abs(d_.get("d_nivel_2t", 0)), abs(d_.get("d_margem_1t", 0))))
# ----------------------------------------------------------------------------
# 6b. DIAGNÓSTICO DE VIÉS E DISPERSÃO POR INSTITUTO (resíduos do modelo único)
# ----------------------------------------------------------------------------
def moe95(n_): return 1.96 * math.sqrt(0.25 / n_) * 100
df["x_tend"] = [float(fit_full["x"][d_]) for d_ in df.dia]
df["resid_spline"] = df.margem_valid - df.x_tend # desvio da tendência comum (casa + ruído)
df["resid_full"] = df.resid_spline - df.house_aplicado # após remover o efeito de casa (o aplicado: λ·h depois do 1º turno)
df["moe_declarada"] = df.n.map(moe95)
df["moe_margem_95"] = 1.96 * df.se_margem_teorico
diag = []
for i_ in insts:
sub = df[df.instituto == i_]
n_i = len(sub)
# variância posterior do efeito de casa vinda do próprio ajuste (conta a_casa = λ nas ondas pós-1º turno, que identificam só λ·h);
# a soma simples das precisões superestimava a informação dessas ondas
_hv = (fit_win.get("house_var") or {}).get(i_, (fit_full.get("house_var") or {}).get(i_))
se_h = math.sqrt(_hv) if _hv else math.sqrt(1.0 / (sub.w_precisao.sum() + 1/1.5**2))
rms_tend = float(np.sqrt((sub.resid_spline ** 2).mean()))
rms_full = float(np.sqrt((sub.resid_full ** 2).mean()))
se_teo = float(sub.se_margem_teorico.mean())
diag.append({"instituto": i_, "n_ondas": int(n_i), "n_medio": int(sub.n.mean()),
"metodo": sub.sort_values("campo_fim").metodo.iloc[-1], "contratante": sub.sort_values("campo_fim").contratante.iloc[-1],
"house": round(house[i_], 2), "house_se": round(se_h, 2), "house_z": round(house[i_] / se_h, 1),
"sigma_extra": round(sigma_inst[i_], 2), "ancora": i_ in ANCORA,
"rms_vs_tendencia": round(rms_tend, 2), "rms_idiossincratico": round(rms_full, 2),
"moe_declarada": round(float(sub.moe_declarada.mean()), 2), "moe_margem_95": round(float(sub.moe_margem_95.mean()), 2),
"se_margem_teorico": round(se_teo, 2), "razao_erro_teorico": round(rms_tend / se_teo, 2),
"mae_hist": round(float(mae_inst[i_]), 2) if i_ in mae_inst.index else None,
"n_obs_hist": int(n_inst[i_]) if i_ in n_inst.index else 0, "peso_qualidade": round(peso_qualidade(i_), 2),
"ultima_onda": sub.campo_fim.max().strftime("%d/%m"), "diagnostico_confiavel": bool(n_i >= 3)})
diag = sorted(diag, key=lambda d: -d["n_ondas"])
rms_tend_global = float(np.sqrt((df.resid_spline ** 2).mean()))
se_teo_global = float(df.se_margem_teorico.mean())
moe_decl_global = float(df.moe_declarada.mean()); moe_margem_global = float(df.moe_margem_95.mean())
razao_global = rms_tend_global / se_teo_global
sigma2 = float((df.resid_full**2).mean())
recentes = df[df.idade_dias <= 21]
# Teste de manada (auditoria de 3/out): compara a dispersão dos RESÍDUOS (margem − tendência − efeito de casa) com o erro amostral.
# A versão anterior usava as margens brutas, que carregam as diferenças permanentes entre institutos: razão acima de 1 era certa
# e não dizia nada. Razão bem abaixo de 1 nos resíduos indicaria pesquisas mais parecidas entre si do que o acaso permite.
sd_bruto_recente = float(recentes.margem_valid.std(ddof=1))
sd_obs_recente = float(np.sqrt((recentes.resid_full ** 2).mean())); sd_esp_recente = float(np.sqrt((recentes.se_margem_teorico ** 2).mean()))
herding_ratio = sd_obs_recente / sd_esp_recente
# ----------------------------------------------------------------------------
# 5b. MIGRAÇÃO — ausentes, terceira via e brancos/nulos por cadeia de Markov + Monte Carlo (migracao_markov.py). Análise paralela e
# condicional: parte do resultado oficial por UF e da 2ª opção declarada, sorteia a matriz de transição (Dirichlet) e os fluxos
# brutos de comparecimento, simula dia a dia a campanha de conversa pessoal do 2º turno (guia "Três nomes, três semanas") e a
# resposta de Flávio, e compara com as metas do guia. Não altera nenhuma saída do modelo principal (manchete).
# ----------------------------------------------------------------------------
import migracao_markov
migracao_res = {"usado": False, "motivo": "só com o resultado oficial do 1º turno por UF"}
if POS_1T and migracao_markov.disponivel(DATA):
_ini_cad = str((_tr.campo_fim.max() + pd.Timedelta(days=1)).date()) if (_tr.campo_fim > T1).any() else None # 2ª opção pós-1º turno: a cadeia começa depois do campo
migracao_res = migracao_markov.simular(DATA, transf, HOJE, sd_deriva, regiao_uf=REGIAO_UF, transf_fonte=TRANSF_FONTE, inicio_cadeia=_ini_cad,
comparacao={"estrutural_base_margem": round(base_m, 2), "p_lula_principal": round(p_lula, 3), "p_lula_mcmc": round(p_lula_mcmc, 3),
"mediana_principal": round(float(q[3]), 1), "sd_deriva_principal": round(sd_deriva, 2)})
_mg = {c_["id"]: c_ for c_ in migracao_res["cenarios"] if c_["resposta"] == "nenhuma"}
print(f"[Migração] {migracao_res['n_sorteios']} sorteios × {len(migracao_res['cenarios'])} células | sem campanha {_mg['sem']['margem_q']['50']:+.2f} "
f"(P(Lula) com deriva {_mg['sem']['p_lula_com_deriva']:.3f}; principal {p_lula:.3f}) | forte {_mg['forte']['margem_q']['50']:+.2f} "
f"(saldo {_mg['forte']['saldo_frentes']['total']['q50']/1e6:+.2f} mi; P {_mg['forte']['p_lula_com_deriva']:.3f}) | metas do guia: margem "
f"{migracao_res['guia']['margem_se_cumprido']:+.2f}, conversão necessária {migracao_res['guia']['taxa_necessaria']['terceira_via']['razao']:.0f}× a literatura "
f"| checks {'ok' if all(migracao_res['checks'].values()) else 'FALHOU'} | {migracao_res['segundos']} s")
if not all(migracao_res["checks"].values()):
print(f"[AVISO] migração: verificação falhou: {migracao_res['checks']}")
# ----------------------------------------------------------------------------
# 7. SAÍDAS — JSON + CSVs + SVGs
# ----------------------------------------------------------------------------
res = {
"data_base": str(HOJE.date()), "atualizado_em": pd.Timestamp.now(tz="America/Sao_Paulo").strftime("%Y-%m-%dT%H:%M"), "versao_modelo": 3,
"influencia": influencia, "df_erro": DF_ERRO, "mae_rel": {i: round(float(mae_rel[i]), 2) for i in mae_rel.index}, "mae_ref": MAE_REF,
"erro_comum_1t": (None if ERRO_COMUM_1T is None else round(ERRO_COMUM_1T, 2)), "rho_1t_2t": RHO_1T_2T, "sd_quebra_1t": SD_QUEBRA_1T,
"nivel_pesquisas": round(level_pesq, 2), "se_pesquisas": round(se_pesq, 2), "nivel_estrutural": (None if NIVEL_ESTRUT is None else round(NIVEL_ESTRUT, 2)),
"sd_estrutural": round(SD_ESTRUT_HOJE, 2), "sd_estrutural_base": SD_ESTRUT, "peso_estrutural": round(W_ESTRUT, 3), "f_pos1t": round(F_POS1T, 3), "n_pos1t": N_POS1T, "bias_pre1t_obs": None if BIAS_PRE1T_OBS is None else round(BIAS_PRE1T_OBS, 2), "bias_pos_regra": None if BIAS_POS_REGRA is None else round(BIAS_POS_REGRA, 2), "mercado_valido": MERC_VALIDO, "transf_fonte": TRANSF_FONTE,
"transf": {k_: [round(v[0], 2), round(v[1], 2)] for k_, v in transf.items()},
"t1_margem_nivel": round(share_now["lula"] - share_now["flavio"], 2), "t1_margem_se": round(SE_M1, 2), "t1_sig_rw_margem": round(float(fit_m1["sig_rw"]), 3),
"t1_house_margem": {i_: round(v, 2) for i_, v in fit_m1["house"].items()}, "var_ancora": round(fit_win["var_ancora"], 3), "regime_pos_1t": POS_1T, "n_pesquisas": int(len(df)), "n_institutos": int(df.inst_base.nunique()), "n_series_casa": len(insts),
"ancora": ANCORA, "q_ancora": {k_: round(v, 2) for k_, v in Q_ANCORA.items()}, "inicio_janela": str(INICIO_JANELA.date()), "n_pesquisas_janela": int(len(jan)),
"diagnostico_institutos": diag, "rms_vs_tendencia_global": round(rms_tend_global, 2),
"moe_declarada_media": round(moe_decl_global, 2), "moe_margem_95_media": round(moe_margem_global, 2),
"se_margem_teorico_medio": round(se_teo_global, 2), "razao_erro_teorico_global": round(razao_global, 2),
"herding": {"sd_observado": round(sd_obs_recente, 2), "sd_esperado_amostral": round(sd_esp_recente, 2), "razao": round(herding_ratio, 2), "n_ondas_21d": int(len(recentes)), "sd_bruto": round(sd_bruto_recente, 2)},
"validacao_rolling": VALIDACAO,
"nivel_ajustado_hoje_margem_valid": round(level_final, 2), "se_total": round(se_final, 2),
"nivel_pspline": round(level_pspline, 2), "se_pspline": round(se_pspline, 2), "nivel_kalman": round(level_kf, 2), "se_kalman": round(se_kf, 2),
"nivel_nacional_combinado": round(level_nac, 2), "se_nacional_combinado": round(se_nac, 2), "nivel_v2_like": round(level_v2_like, 2), "shift_ancora": round(_shift_v2, 2),
"sigma_passeio_estimado": round(SIG_RW_KF, 3), "sigma_passeio_ml": round(SIG_RW_ML, 3), "sigma_extra_kalman": round(float(np.median(list(sigma_inst.values()))), 2), "sd_diario_usado": round(SD_DIARIO, 3),
"sig_entre_turnos_dia": round(SIG_ENTRE_DIA, 3), "sig_entre_turnos_bruto": round(SIG_ENTRE_DIA_BRUTO, 3), "mult_entre_turnos": MULT_ENTRE_TURNOS, "sd_deriva_antigo": round(sd_deriva_antigo, 2),
"movimento_entre_turnos": mov.to_dict(orient="records"),
"bias_metodo": BIAS_METODO, "bias_n_metodo": {k_: int(v) for k_, v in BIAS_N_METODO.items()}, "bias_aplicado": round(BIAS_APLICADO, 2), "bias_sd_aplicado": round(BIAS_SD, 2), "bias_pre1t_mean": BIAS_PRE1T_MEAN, "bias_pre1t_sd": BIAS_PRE1T_SD, "bias_pre1t_ciclos": {str(k_): round(v, 2) for k_, v in BIAS_PRE1T_CICLOS.items()}, "bias_pre1t_hist": BIAS_PRE1T_HIST, "bias_final_mean": BIAS_MEAN,
"bias_2t_ciclos": {str(k_): round(v, 2) for k_, v in bias_2t_ciclos.items()}, "pesos_ciclo": {str(k_): round(v, 2) for k_, v in pesos_ciclo.items()},
"bias_prior_mean_igual": BIAS_MEAN_IGUAL, "sens_bias": sens_bias,
"bias_1t_ciclos": {str(k_): round(v, 2) for k_, v in bias_1t_ciclos.items()}, "bias_1t_hist": BIAS_1T_HIST, "bias_1t_mean": BIAS_1T_MEAN, "bias_1t_sd": BIAS_1T_SD, "encolhimento_1t": ENCOLHIMENTO_1T,
"efeitos_fatores": efeitos_fatores, "abstencao": abst, "sd_ruido_regional": round(SD_RUIDO, 2), "sig_regiao": SIG_REGIAO, "peso_regiao": {k_: round(v, 3) for k_, v in PESO_REGIAO.items()},
"estadual": ({"usado": estadual["usado"], "motivo": estadual.get("motivo", ""), "nivel": round(estadual["nivel"], 2), "se": round(estadual["se"], 2),
"mu_swing": round(estadual["mu_swing"], 2), "mu_regiao": estadual["mu_regiao"],
"ajuste_medio_tend": round(estadual["ajuste_medio_tend"], 2), "ajuste_medio_casa": round(estadual["ajuste_medio_casa"], 2), "tau": round(estadual["tau"], 2), "cobertura": round(estadual["cobertura"], 3),
"n_pesq": estadual["n_pesq"], "n_uf": estadual["n_uf"], "peso_na_combinacao": round(estadual.get("peso_na_combinacao", 0.0), 3),
"por_uf": estadual["por_uf"][["uf","n_pesq","institutos","ultimo_campo","m22","margem_2026","swing_bruto","swing","peso"]].round(3).to_dict(orient="records")}
if "nivel" in estadual else {"usado": False, "motivo": estadual["motivo"]}),
"p_flavio_2t_usado": round(P_FLAVIO_2T, 3), "sig_rw_menores": round(SIG_RW_MENORES, 3), "sig_rw_menores_livre": SIG_RW_MENORES_LIVRE, "janela_menores_dias": JANELA_MENORES, "desloc_colapso_removido": round(_desloc_colapso, 2), "n_max_efetivo": N_MAX_EFETIVO, "n_efetivo_inst": N_EFETIVO_INST, "lambda_casa_pos1t": LAMBDA_CASA, "p_flavio_top2_votos": round(P_FLAVIO_TOP2_VOTOS, 4), "risco_candidatura": RISCO_CANDIDATURA, "p_flavio_2t_mercado": P_FLAVIO_2T_MERC,
"sigma_resid": round(math.sqrt(sigma2), 2),
"media_ponderada_ultimas_bruta": round(media_simples_bruta, 2), "media_ponderada_ultimas_ajustada": round(media_simples_adj, 2),
"house_effects": {i: round(house[i], 2) for i in insts}, "sigma_inst": {i: round(sigma_inst[i], 2) for i in insts},
"mae_hist": {i: round(float(mae_inst[i]), 2) for i in mae_inst.index}, "bias_hist": {i: round(float(bias_inst[i]), 2) for i in bias_inst.index},
"bias_prior_mean": BIAS_MEAN, "bias_prior_sd": BIAS_SD_FINAL, "bias_prior_sd_final": BIAS_SD_FINAL, "sd_deriva": round(sd_deriva, 2),
"p_lula": round(p_lula, 3), "p_lula_vs_flavio": round(p_lula_vs_flavio, 3), "p_empate_1p5": round(p_empate, 3),
"sensibilidade": sens, "p_ensemble": round(p_ens, 3), "p_ensemble_empate": round(p_ens_empate, 3), "pesos_ensemble": W_ENS,
"cenarios_ensemble": {k_: round(v, 3) for k_, v in cen.items()}, "q_ensemble_10_50_90": [round(float(v), 1) for v in q_ens],
"p_mercados": P_MERC, "mercado": MERC, "p_fundamentos": round(P_FUND, 3), "aprovacao_media_corrente": round(aprovacao_media, 1),
"fund_inclinacao": round(float(FUND_B), 2), "fund_empate": round(float(FUND_EMPATE), 1), "fund_margem_esperada": round(mu_fund, 1),
"fund_hist": [{"ciclo": c_, "aprovacao": a_, "margem": m_} for c_, a_, m_ in FUND_HIST], "fund_medida": FUND_MEDIDA, "aprovacao_atual": APROVACAO_ATUAL,
"quantis_margem_final": {str(p): round(float(v), 1) for p, v in zip([5,10,25,50,75,90,95], q)},
"var_share": {k_: round(v, 3) for k_, v in var_share.items()},
"p_dado_lead": tabela_lead, "lead_breakeven": round(lead_breakeven, 1), "lead_70": round(lead_70, 1), "lead_90": round(lead_90, 1),
"estrutural_base_margem": round(base_m, 2), "tornado": tornado_df.round(2).to_dict(orient="records"),
# 1º turno
"t1_n_pesquisas": int(len(p1)), "t1_n_institutos": int(p1.instituto.nunique()), "t1_sig_rw": round(sig_rw_1t, 3),
"t1_share_valid": {c_: round(v, 1) for c_, v in share_now.items()}, "t1_se_share": {c_: round(v, 2) for c_, v in se_share.items()},
"t1_q_share": q_share, "t1_house": {c_: {i_: round(v, 2) for i_, v in house_1t[c_].items()} for c_ in CANDS},
"t1_agregado": {k_: round(v, 1) for k_, v in agg1.items() if k_ in ["L","F","Cury","Renan","Caiado","Zema","outros","branco","indec"]},
"t1_lula_valid_now": round(L_valid_now, 1), "t1_gap": round(gap_valid, 1),
"t1_por_instituto": [{"instituto": r.instituto, "campo_fim": str(r.campo_fim.date()), "n": int(r.n), **{c_: round(float(getattr(r, c_)), 1) for c_ in CANDS},
"branco_nulo": (None if pd.isna(r.branco_nulo) else round(float(r.branco_nulo), 1)), "indecisos": (None if pd.isna(r.indecisos) else round(float(r.indecisos), 1)),
"L_valid": round(float(r.lula_v), 1), "F_valid": round(float(r.flavio_v), 1), "margem_v": round(float(r.margem_v), 1), "registro_tse": (r.registro_tse if isinstance(r.registro_tse, str) else "")}
for r in last1.itertuples()],
"p_1turno": round(p_1t, 4), "p_flavio_1turno": round(p_flavio_1t, 4), "p_lula_lidera_1t": round(p_lula_lidera_1t, 3), "q_1turno": [round(float(v), 1) for v in q_1t],
"q_margem_1t": {str(p): round(float(v), 1) for p, v in zip([5,10,25,50,75,90,95], q_margem_1t)}, "p_terceiro": {c_: round(v, 3) for c_, v in p_terceiro.items()},
"sens_1t": sens_1t, "tabela_1t_2t": tabela_1t_2t, "hist_1t": hist_1t,
"share_lula_3via": {k_: round(v, 2) for k_, v in share_lula_3via.items()}, "T3_total": round(T3_total, 1),
"resultado_1t": RESULTADO_1T,
}
# termômetro de tendência (últimas duas semanas): nível suavizado da janela dia a dia, com a leitura estadual na mesma proporção de hoje.
# É o nível SÓ das pesquisas (sem o voto contado), com o erro do filtro mais o da âncora; o painel o compara com o nível das pesquisas de
# hoje (nivel_pesquisas), não com a manchete mesclada (21/out: antes o erro da série era o do nível mesclado).
_serie = []
for _k in range(14, -1, -1):
_lv, _Pk = x_win(t_now - _k)
_lv = (1 - w_est_comb)*_lv + w_est_comb*(estadual["nivel"] if estadual["usado"] else 0.0)
_serie.append({"data": str((HOJE - pd.Timedelta(days=_k)).date()), "nivel": round(_lv, 2), "se": round(math.sqrt(_Pk + fit_win["var_ancora"]), 2)})
_d7 = _serie[-1]["nivel"] - _serie[-8]["nivel"]; _d14 = _serie[-1]["nivel"] - _serie[0]["nivel"]
_LIM = 0.5
_quem = lambda v: "Lula" if v > 0 else "Flávio"
_dir = (f"a favor de {_quem(_d7)}" if abs(_d7) >= _LIM else (f"a favor de {_quem(_d14)}, desacelerando" if abs(_d14) >= 2*_LIM else "estável"))
_mov = []
_dfs = df.sort_values("campo_fim")
for _r in _dfs[_dfs.campo_fim > HOJE - pd.Timedelta(days=14)].itertuples():
# onda anterior da mesma série; depois do 1º turno só pares pós-4/out, para não contar a quebra de 4/out como movimento
_prev = _dfs[(_dfs.instituto == _r.instituto) & (_dfs.campo_fim < _r.campo_fim) & ((_dfs.campo_fim > T1) if POS_1T else True)]
if len(_prev) == 0: continue
_p = _prev.iloc[-1]
_mov.append({"instituto": _r.instituto, "campo_fim": str(_r.campo_fim.date()), "anterior": str(_p.campo_fim.date()),
"lula": float(_r.lula), "flavio": float(_r.flavio), "lula_ant": float(_p.lula), "flavio_ant": float(_p.flavio),
"delta_margem": round(float(_r.margem_valid - _p.margem_valid), 1)})
res["nivel_ajustado_14d_atras"] = _serie[0]["nivel"]
# mesma linha desde o 1º turno, para o gráfico evolutivo das pesquisas pós-4/out (evolucao_pesquisas.py)
_serie_pos1t = []
for _k in range(max((HOJE - T1).days, 0), -1, -1):
_lv, _Pk = x_win(t_now - _k)
_lv = (1 - w_est_comb)*_lv + w_est_comb*(estadual["nivel"] if estadual["usado"] else 0.0)
_serie_pos1t.append({"data": str((HOJE - pd.Timedelta(days=_k)).date()), "nivel": round(_lv, 2), "se": round(math.sqrt(_Pk + fit_win["var_ancora"]), 2)})
res["serie_pos1t"] = _serie_pos1t
res["termometro"] = {"serie": _serie, "delta_7d": round(_d7, 2), "delta_14d": round(_d14, 2), "limiar": _LIM, "direcao": _dir, "movimentos": _mov,
"n_lula": sum(m["delta_margem"] >= 0.5 for m in _mov), "n_flavio": sum(m["delta_margem"] <= -0.5 for m in _mov),
"n_estavel": sum(abs(m["delta_margem"]) < 0.5 for m in _mov)}
# previsões do 1º turno congeladas (última rodada antes de 4/out fica gravada no repositório; a partir de 4/out nunca se reescreve,
# nem se o arquivo faltar — recriá-lo depois da eleição produziria uma "previsão" feita com o resultado conhecido)
frozen_path = DATA + "previsoes_1turno_congeladas.json"
if HOJE < T1 and not os.environ.get("ELEICAO_NAO_CONGELAR"):
json.dump({"data_base": str(HOJE.date()), "share_valid": res["t1_share_valid"], "q_share": q_share, "p_1turno": res["p_1turno"],
"p_flavio_top2": res["p_flavio_top2_votos"], "p_lula_lidera": res["p_lula_lidera_1t"], "q_margem_1t": res["q_margem_1t"],
"p_terceiro": res["p_terceiro"], "bias_1t_mean": BIAS_1T_MEAN, "ultimas_por_instituto": res["t1_por_instituto"],
"nivel_2t": res["nivel_ajustado_hoje_margem_valid"], "p_lula_2t": res["p_lula"], "p_ensemble_2t": res["p_ensemble"]},
open(frozen_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
if os.path.exists(frozen_path):
res["previsoes_congeladas"] = json.load(open(frozen_path, encoding="utf-8"))
res["mcmc"] = mcmc_res
res["migracao"] = migracao_res
# previsões do 2º turno congeladas, dos dois modelos em paralelo (mesma regra do 1º turno: reescritas a cada rodada até a véspera de
# 25/out e nunca depois), para comparar com o resultado oficial
frozen2_path = DATA + "previsoes_2turno_congeladas.json"
if HOJE < T2 and not os.environ.get("ELEICAO_NAO_CONGELAR"):
json.dump({"data_base": str(HOJE.date()),
"principal": {"p_lula": res["p_lula"], "p_ensemble": res["p_ensemble"], "quantis_margem_final": res["quantis_margem_final"],
"q_ensemble_10_50_90": res["q_ensemble_10_50_90"], "nivel_pesquisas": res["nivel_pesquisas"], "nivel_ajustado": res["nivel_ajustado_hoje_margem_valid"],
"nivel_verdadeiro": mcmc_res["comparacao"]["nivel_verdadeiro_principal"]},
"mcmc": {k_: mcmc_res[k_] for k_ in ["p_lula", "p_ensemble", "quantis_margem_final", "q_ensemble_10_50_90", "nivel_hoje",
"se_nivel_hoje", "rhat_max", "ess_min", "convergiu"]}},
open(frozen2_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
if os.path.exists(frozen2_path):
res["previsoes_2turno_congeladas"] = json.load(open(frozen2_path, encoding="utf-8"))
json.dump(res, open(OUT + "resultados.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
def _csv(d_, path, nd=3):
d_ = d_.copy()
for c_ in d_.select_dtypes(include="number").columns: d_[c_] = d_[c_].round(nd)
d_.to_csv(path, index=False)
_csv(df, OUT + "pesquisas_2turno_2026_ponderadas.csv")
last["sigma_inst"] = last.instituto.map(sigma_inst)
_csv(last[["instituto","campo_fim","n","n_ef","lula","flavio","margem_valid","margem_adj","house_aplicado","w_recencia","w_tamanho","w_qualidade","w","sigma_inst","w_precisao"]], OUT + "ultimas_por_instituto.csv")
_csv(p1, OUT + "pesquisas_1turno_2026_processadas.csv", 2)
# ---------------- SVG helpers ----------------
def fmt(v, d=1): return f"{v:.{d}f}".replace(".", ",")
LABEL = {"Parana Pesquisas": "Paraná Pesquisas", "Futura Inteligencia": "Futura Inteligência", "MDA": "CNT/MDA", "Palver v3": "Palver (calibração v3)", "MDA v2": "CNT/MDA (ponderada pelo 1º turno)", "Futura Inteligencia v2": "Futura Inteligência (escolaridade pela PNAD)",
"Verita": "Veritá", "Alfa Inteligencia": "Alfa Inteligência", "Vetor": "Vetor/Arrow",
"Instituto Franca": "Instituto França"}
def svg_trend():
W, H = 900, 300
x0, x1, y0, y1 = 60, 880, 20, 250
tmin, tmax = int(df.dia.min()) - 1, (T2 - T0).days
ymin, ymax = -12, 20
def X(t): return x0 + (t - tmin)/(tmax - tmin)*(x1-x0)
def Y(v): return y1 - (v - ymin)/(ymax - ymin)*(y1-y0)
s = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Margem Lula menos Flávio em votos válidos, pesquisas e tendência ajustada por efeitos de casa">']
for v in range(-12, 21, 4):
s.append(f'<line class="grid-line" x1="{x0}" y1="{Y(v):.1f}" x2="{x1}" y2="{Y(v):.1f}"/>')
s.append(f'<text class="axis-label" x="{x0-8}" y="{Y(v)+4:.1f}" text-anchor="end">{"+" if v>0 else ""}{v}</text>')
s.append(f'<line x1="{x0}" y1="{Y(0):.1f}" x2="{x1}" y2="{Y(0):.1f}" stroke="var(--ink-soft)" stroke-width="1.2"/>')
MESES = {1:"jan",2:"fev",3:"mar",4:"abr",5:"mai",6:"jun",7:"jul",8:"ago",9:"set",10:"out",11:"nov",12:"dez"}
for m in pd.date_range("2025-12-01", "2026-11-01", freq="MS"):
tt = (m - T0).days
if tmin-15 <= tt <= tmax:
s.append(f'<text class="axis-label" x="{X(tt):.1f}" y="{y1+22}" text-anchor="middle">{MESES[m.month]}</text>')
up = " ".join(f"{X(t):.1f},{Y(f+1.96*e):.1f}" for t, f, e in zip(tt_grid, f_grid, se_grid))
lo = " ".join(f"{X(t):.1f},{Y(f-1.96*e):.1f}" for t, f, e in zip(tt_grid[::-1], f_grid[::-1], se_grid[::-1]))
s.append(f'<polygon points="{up} {lo}" fill="var(--accent)" opacity="0.13"/>')
s.append('<polyline class="series-line" stroke="var(--accent)" stroke-width="2.4" points="' + " ".join(f"{X(t):.1f},{Y(f):.1f}" for t, f in zip(tt_grid, f_grid)) + '"/>')
for _, r in df.iterrows():
s.append(f'<circle cx="{X(r.t):.1f}" cy="{Y(r.margem_valid):.1f}" r="4" fill="var(--other)" stroke="var(--surface)" stroke-width="1.2" opacity="0.85" class="iso-dot" tabindex="0"><title>{LABEL.get(r.instituto, r.instituto)} · {r.campo_fim.strftime("%d/%m/%y")} · Lula {fmt(r.lula)} × Flávio {fmt(r.flavio)} → margem válida {fmt(r.margem_valid)} · efeito de casa {fmt(r.house, 1)}</title></circle>')
marcos = [(t_now, "hoje", "end"), ((T1 - T0).days, "1º turno", "middle"), (tmax, "2º turno", "end")]
xs = [X(tt) for tt, _, _ in marcos]
rows = [0]
for i in range(1, len(xs)):
rows.append(1 if abs(xs[i] - xs[i-1]) < 58 and rows[i-1] == 0 else 0)
for (tt, lab, anchor), xr, row in zip(marcos, xs, rows):
s.append(f'<line x1="{xr:.1f}" y1="{y0}" x2="{xr:.1f}" y2="{y1}" stroke="var(--line)" stroke-dasharray="3,4"/>')
dx = -4 if anchor == "end" else 0
ry = (y0 - 6) - (14 if row else 0)
s.append(f'<text class="axis-label" x="{xr+dx:.1f}" y="{ry}" text-anchor="{anchor}">{lab}</text>')
tq = tmax; med = q[3]; p10, p90 = q[1], q[5]
cor_proj = "var(--lula)" if med > 0 else "var(--flavio)"
s.append(f'<line x1="{X(tq):.1f}" y1="{Y(p10):.1f}" x2="{X(tq):.1f}" y2="{Y(p90):.1f}" stroke="{cor_proj}" stroke-width="6" opacity="0.35"/>')
s.append(f'<circle cx="{X(tq):.1f}" cy="{Y(med):.1f}" r="5" fill="{cor_proj}"/>')
s.append(f'<text class="dot-label" x="{x1-4:.1f}" y="{y0+16}" text-anchor="end" fill="{cor_proj}">projeção 25/out · mediana {("+" if med>0 else "")}{fmt(med)}</text>')
s.append(f'<text class="dot-label" x="{x1-4:.1f}" y="{y0+30}" text-anchor="end" fill="{cor_proj}">intervalo 80%: {fmt(p10)} a {("+" if p90>0 else "")}{fmt(p90)}</text>')
s.append(f'<circle cx="{X(t_now):.1f}" cy="{Y(level_final):.1f}" r="5.5" fill="var(--accent)" stroke="var(--surface)" stroke-width="1.5"><title>Nível hoje: {fmt(level_final)} ± {fmt(1.96*se_final)}</title></circle>')
s.append(f'<text class="dot-label" x="{X(t_now)-8:.1f}" y="{Y(level_final)+26:.1f}" text-anchor="end" fill="var(--accent)">hoje {("+" if level_final>0 else "")}{fmt(level_final)} ±{fmt(1.96*se_final)}</text>')
s.append("</svg>")
return "\n".join(s)
def svg_hist():
W, H = 900, 260
x0, x1, y0, y1 = 60, 880, 44, 220
bins = np.arange(-14, 18.5, 1.0)
h, edges = np.histogram(margem_final, bins=bins, density=True)
hmax = h.max()
def X(v): return x0 + (v - bins[0])/(bins[-1]-bins[0])*(x1-x0)
def Y(d): return y1 - d/hmax*(y1-y0)
s = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Distribuição simulada da margem final Lula menos Flávio em 25 de outubro">']
for i, d in enumerate(h):
c = "var(--lula)" if edges[i] >= 0 else "var(--flavio)"
quem = "Lula" if edges[i] >= 0 else "Flávio"
s.append(f'<rect x="{X(edges[i])+1:.1f}" y="{Y(d):.1f}" width="{(x1-x0)/len(h)-2:.1f}" height="{y1-Y(d):.1f}" fill="{c}" opacity="{0.85 if abs(edges[i]+0.5)>1.5 else 0.45}" tabindex="0"><title>margem {fmt(edges[i])} a {fmt(edges[i]+1)} p.p. · {quem} vence nessa faixa · densidade {d/hmax*100:.0f}% do pico</title></rect>')
s.append(f'<line x1="{X(0):.1f}" y1="{y0}" x2="{X(0):.1f}" y2="{y1}" stroke="var(--ink)" stroke-width="1.2"/>')
for v in range(-12, 17, 4):
s.append(f'<text class="axis-label" x="{X(v):.1f}" y="{y1+18}" text-anchor="middle">{"+" if v>0 else ""}{v}</text>')
s.append(f'<text class="axis-label" x="{(x0+x1)/2:.0f}" y="{y1+34}" text-anchor="middle">margem Lula − Flávio em 25/out (p.p. de votos válidos)</text>')
# rótulos em cor de texto; a cor da série fica no quadrado ao lado (Ardósia: texto de gráfico nunca na cor da série)
s.append(f'<rect x="{X(6.5):.1f}" y="{y0+5}" width="9" height="9" rx="1" fill="var(--lula)"/><text class="dot-label" x="{X(6.5)+14:.1f}" y="{y0+14}" fill="var(--ink)">Lula vence · {p_lula*100:.0f}%</text>')
s.append(f'<rect x="{X(-6.5)-9:.1f}" y="{y0+5}" width="9" height="9" rx="1" fill="var(--flavio)"/><text class="dot-label" x="{X(-6.5)-14:.1f}" y="{y0+14}" text-anchor="end" fill="var(--ink)">Flávio vence · {(1-p_lula)*100:.0f}%</text>')
s.append(f'<text class="dot-label" x="{X(0):.1f}" y="{y0-16}" text-anchor="middle" fill="var(--ink-soft)">faixa clara = |margem| < 1,5 p.p. (resultado contestável): {p_empate*100:.0f}%</text>')
s.append("</svg>")
return "\n".join(s)
def svg_mcmc():
"""Os dois modelos lado a lado: barras = modelo principal (Monte Carlo com parâmetros fixos), linha = MCMC (posterior completo)."""
W, H = 900, 270
x0, x1, y0, y1 = 60, 880, 56, 220
bins = np.arange(-20, 20.5, 1.0)
h_a, edges = np.histogram(np.clip(margem_final, -19.99, 19.99), bins=bins, density=True)
h_m, _ = np.histogram(np.clip(margem_mcmc, -19.99, 19.99), bins=bins, density=True)
hmax = max(h_a.max(), h_m.max())
def X(v): return x0 + (v - bins[0])/(bins[-1]-bins[0])*(x1-x0)
def Y(d): return y1 - d/hmax*(y1-y0)
s = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Distribuição da margem final em 25 de outubro: modelo principal em barras, modelo MCMC em linha">']
bw = (x1-x0)/len(h_a)
for i, d in enumerate(h_a):
s.append(f'<rect x="{X(edges[i])+1:.1f}" y="{Y(d):.1f}" width="{bw-2:.1f}" height="{y1-Y(d):.1f}" fill="var(--ink-3)" opacity="0.32" tabindex="0">'
f'<title>margem {fmt(edges[i])} a {fmt(edges[i]+1)} p.p. · principal {d*100:.1f}% · MCMC {h_m[i]*100:.1f}% das simulações</title></rect>')
pts = []
for i, d in enumerate(h_m):
pts += [f"{X(edges[i]):.1f},{Y(d):.1f}", f"{X(edges[i+1]):.1f},{Y(d):.1f}"]
s.append(f'<polyline points="{" ".join(pts)}" fill="none" stroke="var(--accent)" stroke-width="2.4" stroke-linejoin="round"/>')
s.append(f'<line x1="{X(0):.1f}" y1="{y0-6}" x2="{X(0):.1f}" y2="{y1}" stroke="var(--ink)" stroke-width="1.2"/>')
for med, cor, dy in [(q[3], "var(--ink-3)", 0), (q_mcmc[3], "var(--accent)", 0)]:
s.append(f'<line x1="{X(med):.1f}" y1="{y0-6}" x2="{X(med):.1f}" y2="{y1}" stroke="{cor}" stroke-width="1.4" stroke-dasharray="4,3"/>')
for v in range(-20, 21, 4):
s.append(f'<text class="axis-label" x="{X(v):.1f}" y="{y1+18}" text-anchor="middle">{"+" if v>0 else ""}{v}</text>')
s.append(f'<text class="axis-label" x="{(x0+x1)/2:.0f}" y="{y1+36}" text-anchor="middle">margem Lula − Flávio em 25/out (p.p. de votos válidos; caudas além de ±20 somadas nas pontas)</text>')
s.append(f'<rect x="{x0}" y="{y0-44}" width="14" height="10" fill="var(--ink-3)" opacity="0.32"/>')
s.append(f'<text class="dot-label" x="{x0+20}" y="{y0-35}" fill="var(--ink-soft)">principal (Monte Carlo): Lula {p_lula*100:.0f}% · mediana {fmt(q[3])} · 80% de {fmt(q[1])} a {fmt(q[5])}</text>')
s.append(f'<line x1="{x0}" y1="{y0-19}" x2="{x0+14}" y2="{y0-19}" stroke="var(--accent)" stroke-width="2.4"/>')
s.append(f'<text class="dot-label" x="{x0+20}" y="{y0-15}" fill="var(--accent)">MCMC (posterior completo): Lula {p_lula_mcmc*100:.0f}% · mediana {fmt(q_mcmc[3])} · 80% de {fmt(q_mcmc[1])} a {fmt(q_mcmc[5])}</text>')
s.append("</svg>")
return "\n".join(s)
def svg_tornado():
W = 900; rowh = 34; H = 40 + rowh*len(tornado_df) + 30
x0, x1 = 400, 880; vmax = max(tornado_df.alto.abs().max(), tornado_df.baixo.abs().max()) + 0.5
def X(v): return (x0+x1)/2 + v/vmax*(x1-x0)/2
s = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Tornado: efeito de cada variável sobre a margem de Lula no 2º turno">']
for v in [-4, -2, 0, 2, 4]:
if abs(v) <= vmax:
s.append(f'<line class="grid-line" x1="{X(v):.1f}" y1="30" x2="{X(v):.1f}" y2="{H-30}"/>')
s.append(f'<text class="axis-label" x="{X(v):.1f}" y="{H-12}" text-anchor="middle">{"+" if v>0 else ""}{v} p.p.</text>')
s.append(f'<line x1="{X(0):.1f}" y1="30" x2="{X(0):.1f}" y2="{H-30}" stroke="var(--ink)" stroke-width="1.2"/>')
for i, (_, r) in enumerate(tornado_df.iterrows()):
y = 40 + i*rowh
lo, hi = sorted([r.baixo, r.alto])
s.append(f'<text x="{x0-16}" y="{y+13}" text-anchor="end" font-size="12.5" fill="var(--ink)">{r.variavel}</text>')
tt_lab = f'<title>{r.variavel}: {fmt(lo)} a +{fmt(hi)} p.p. na margem de Lula · {r.obs}</title>'
if lo < 0: s.append(f'<rect x="{X(lo):.1f}" y="{y}" width="{X(min(hi,0))-X(lo):.1f}" height="20" fill="var(--flavio)" opacity="0.8" tabindex="0">{tt_lab}</rect>')
if hi > 0: s.append(f'<rect x="{X(max(lo,0)):.1f}" y="{y}" width="{X(hi)-X(max(lo,0)):.1f}" height="20" fill="var(--lula)" opacity="0.8" tabindex="0">{tt_lab}</rect>')
s.append(f'<text class="dot-label" x="{X(lo)-6:.1f}" y="{y+14}" text-anchor="end" fill="var(--ink-soft)">{fmt(lo)}</text>')
s.append(f'<text class="dot-label" x="{X(hi)+6:.1f}" y="{y+14}" fill="var(--ink-soft)">+{fmt(hi)}</text>')
s.append("</svg>")
return "\n".join(s)
def svg_house():
dd = sorted([d for d in diag if d["diagnostico_confiavel"]], key=lambda d: d["house"])
W = 900; rowh = 26; H = 54 + rowh * len(dd) + 26
x0, x1 = 240, 870
vmax = max(abs(d["house"]) + 1.96 * d["house_se"] for d in dd) + 0.4
def X(v): return (x0 + x1) / 2 + v / vmax * (x1 - x0) / 2
s_ = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Efeito de casa de cada instituto na margem Lula menos Flávio, com intervalo de 95%">']
s_.append(f'<text class="axis-label" x="{X(-vmax*0.55):.0f}" y="20" text-anchor="middle">mede mais Flávio que a âncora</text>')
s_.append(f'<text class="axis-label" x="{X(vmax*0.55):.0f}" y="20" text-anchor="middle">mede mais Lula que a âncora</text>')
for v in range(-6, 7, 2):
if abs(v) <= vmax:
s_.append(f'<line class="grid-line" x1="{X(v):.1f}" y1="30" x2="{X(v):.1f}" y2="{H-26}"/>')
s_.append(f'<text class="axis-label" x="{X(v):.1f}" y="{H-8}" text-anchor="middle">{"+" if v>0 else ""}{v}</text>')
s_.append(f'<line x1="{X(0):.1f}" y1="30" x2="{X(0):.1f}" y2="{H-26}" stroke="var(--ink)" stroke-width="1.2"/>')
for i_, d in enumerate(dd):
yy = 44 + i_ * rowh
lo, hi = d["house"] - 1.96 * d["house_se"], d["house"] + 1.96 * d["house_se"]
cor = "var(--lula)" if d["house"] > 0 else "var(--flavio)"
signif = abs(d["house_z"]) >= 1.96
nome = LABEL.get(d["instituto"], d["instituto"]) + (" ◆" if d["ancora"] else "")
s_.append(f'<text x="{x0-14}" y="{yy+4}" text-anchor="end" font-size="12" font-weight="{"600" if d["ancora"] else "400"}" fill="var(--ink)">{nome}</text>')
s_.append(f'<line x1="{X(lo):.1f}" y1="{yy}" x2="{X(hi):.1f}" y2="{yy}" stroke="{cor}" stroke-width="2" opacity="0.45"/>')
s_.append(f'<circle cx="{X(d["house"]):.1f}" cy="{yy}" r="{5 if signif else 3.5}" fill="{cor}" opacity="{1 if signif else 0.55}" tabindex="0">'
f'<title>{nome}: efeito de casa {fmt(d["house"])} p.p. (IC 95%: {fmt(lo)} a {fmt(hi)}) · {d["n_ondas"]} ondas · variância extra estimada σ={fmt(d["sigma_extra"])} · '
f'erro típico {fmt(d["rms_vs_tendencia"])} p.p. vs. {fmt(d["se_margem_teorico"])} esperado pela amostragem</title></circle>')
s_.append(f'<text class="dot-label" x="{X(hi)+7:.1f}" y="{yy+4}" fill="var(--ink-3)">{fmt(d["house"], 1)}{"*" if signif else ""}</text>')
s_.append("</svg>")
return "\n".join(s_)
def svg_1turno():
"""Barras horizontais: share em válidos por candidato, mediana e intervalo 80% da simulação; marca a linha de 50%."""
cands = sorted(CANDS, key=lambda c_: -share_now[c_])
W = 900; rowh = 34; H = 40 + rowh*len(cands) + 34
x0, x1 = 180, 860; vmax = 60
def X(v): return x0 + v/vmax*(x1-x0)
s = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Votos válidos esperados por candidato no 1º turno, com intervalo de 80%">']
for v in range(0, 61, 10):
s.append(f'<line class="grid-line" x1="{X(v):.1f}" y1="30" x2="{X(v):.1f}" y2="{H-30}"/>')
s.append(f'<text class="axis-label" x="{X(v):.1f}" y="{H-12}" text-anchor="middle">{v}%</text>')
s.append(f'<line x1="{X(50):.1f}" y1="30" x2="{X(50):.1f}" y2="{H-30}" stroke="var(--ink)" stroke-width="1.2" stroke-dasharray="4,3"/>')
s.append(f'<text class="axis-label" x="{X(50)+4:.1f}" y="26">50% + 1 decide no 1º turno</text>')
for i, c_ in enumerate(cands):
y = 40 + i*rowh
lo, med, hi = q_share[c_]
cor = "var(--lula)" if c_ == "lula" else ("var(--flavio)" if c_ == "flavio" else "var(--other)")
s.append(f'<text x="{x0-12}" y="{y+14}" text-anchor="end" font-size="12.5" fill="var(--ink)">{CAND_LABEL[c_]}</text>')
s.append(f'<rect x="{X(0):.1f}" y="{y+2}" width="{X(med)-X(0):.1f}" height="18" fill="{cor}" opacity="0.75" tabindex="0"><title>{CAND_LABEL[c_]}: mediana {fmt(med)}% dos válidos · 80% entre {fmt(lo)} e {fmt(hi)} · nível nas pesquisas hoje {fmt(share_now[c_])}%</title></rect>')
s.append(f'<line x1="{X(lo):.1f}" y1="{y+11}" x2="{X(hi):.1f}" y2="{y+11}" stroke="var(--ink)" stroke-width="2"/>')
s.append(f'<line x1="{X(lo):.1f}" y1="{y+5}" x2="{X(lo):.1f}" y2="{y+17}" stroke="var(--ink)" stroke-width="1.5"/><line x1="{X(hi):.1f}" y1="{y+5}" x2="{X(hi):.1f}" y2="{y+17}" stroke="var(--ink)" stroke-width="1.5"/>')
s.append(f'<text class="dot-label" x="{X(hi)+8:.1f}" y="{y+15}" fill="var(--ink)">{fmt(med)}% <tspan fill="var(--ink-3)">({fmt(lo)}–{fmt(hi)})</tspan></text>')
s.append("</svg>")
return "\n".join(s)
open(OUT + "svg_trend.html", "w", encoding="utf-8").write(svg_trend())
open(OUT + "svg_hist.html", "w", encoding="utf-8").write(svg_hist())
open(OUT + "svg_mcmc.html", "w", encoding="utf-8").write(svg_mcmc())
open(OUT + "svg_house.html", "w", encoding="utf-8").write(svg_house())
open(OUT + "svg_tornado.html", "w", encoding="utf-8").write(svg_tornado())
open(OUT + "svg_1turno.html", "w", encoding="utf-8").write(svg_1turno())
print(json.dumps({k_: res[k_] for k_ in ["nivel_ajustado_hoje_margem_valid","se_total","nivel_v2_like","shift_ancora","house_effects","sigma_inst","bias_prior_mean","bias_prior_sd",
"bias_2t_ciclos","sd_deriva","sig_entre_turnos_dia","p_lula","p_lula_vs_flavio","p_empate_1p5","sensibilidade","sens_bias","p_ensemble",
"cenarios_ensemble","efeitos_fatores","var_share","t1_share_valid","p_1turno","p_flavio_top2_votos","p_lula_lidera_1t","q_margem_1t","p_terceiro","sens_1t","validacao_rolling"]},
ensure_ascii=False, indent=1))
print(tornado_df.round(2).to_string())
print("MAE hist:", mae_inst.round(2).to_dict())
print("bias por eleição/turno:", bias_el.round(2).to_dict())