Reverse Engineering: Building a Working Model and Product from Limited Data.
Reverse-engineering the actual continuous multipliers behind integer UI: sourced from a developer presentation slide. Then building a working prototype from the data.
Part 1: how ballpark effects actually work in the sim
The UI ratings are not the modifiers
There has been long-running speculation in the WIS community about whether the UI ballpark ratings (displayed as integer values like −2, +4) are rounded, truncated, or otherwise discretized from underlying fractional modifiers. The answer, sourced from a Paul Bessire (WIS employee) presentation, is that they are none of those things, the UI ratings are simply category labels. The actual modifiers applied in the simulation’s decision tree are continuous floating-point values, not the integers shown in the interface.
Each hit-type event (1B, 2B, 3B, HR) operates on a different scale. The UI rating is best understood as a bucket that maps to a range of possible modifier values, not a discrete multiplier in itself.
Confirmed data points
From the Bessire presentation slide, the following specific modifier values are known, this is what allowed me to calibrate the rest of the values. Petco and the 1B modifier gets a specific note below:
| Data Point | Modifier | Note |
|---|---|---|
| 1B (−2 / Petco era) | 0.908 | Known from Bessire presentation slide |
| 1B (+3 / Coors) | 1.182 | Known from Bessire presentation slide |
| 2B (−4) | 0.795 | Known from Bessire presentation slide |
| 2B (+4) | 1.291 | Known from Bessire presentation slide |
| 3B (−3) | 0.523 | Known from Bessire presentation slide |
| 3B (+4) | 1.868 | Known from Bessire presentation slide |
| HR/LF (−4) | 0.634 | Known from Bessire presentation slide |
| HR/LF (+4) | 1.385 | Known from Bessire presentation slide |
| HR/RF (−4) | 0.636 | Known from Bessire presentation slide |
| HR/RF (+4) | 1.374 | Known from Bessire presentation slide |
| PF (−3 / current Petco) | 0.820 | Park Factor is display-only; not used in sim decision tree |
| PF (+4) | 1.370 | Park Factor is display-only; not used in sim decision tree |
The Park Factor (PF) number displayed in the UI is not used anywhere in the simulation’s decision tree. It is solely a representation of the park’s overall run-scoring environment, a summary statistic, not an input/output. The 1B, 2B, 3B, and HR ratings are what actually drive park outcomes.
One important note on the 1B scale: I plugged everything exactly as you’d see it, with the exception of Petco’s hit modifier. In the original presentation this was a -2 and Petco had a Park Factor (Runs) of .830, now it is a -3 and Petco is displaying a Park Factor of .820. So, I have the extreme negative hit factor displayed in -2 instead of -3. This also helped keep the bounds for chart 2 tangible, as if the .908 was a -3, then all the other bounds would’ve been linear, but the hit (1B) factor would have been exponential and that wouldn’t make sense. This shift to accommodate the change in Petco’s rating over the first few seasons of its existence made everything else fit exceptionally well.
The full modifier scale
Using the confirmed data points and interpolating the intermediate values (the data fits well linearly for all events, with 3B being the notable standout as it has a much steeper slope), the full scale for each event is:
| Event | -4 | -3 | -2 | -1 | 0 | +1 | +2 | +3 | +4 |
|---|---|---|---|---|---|---|---|---|---|
| 1B | 0.816 | 0.862 | 0.908 | 0.954 | 1.000 | 1.046 | 1.092 | 1.138 | 1.184 |
| 2B | 0.800 | 0.850 | 0.900 | 0.950 | 1.000 | 1.050 | 1.100 | 1.150 | 1.200 |
| 3B | 0.400 | 0.550 | 0.700 | 0.850 | 1.000 | 1.150 | 1.300 | 1.450 | 1.700 |
| HR/LF | 0.640 | 0.760 | 0.840 | 0.920 | 1.000 | 1.080 | 1.160 | 1.240 | 1.320 |
| HR/RF | 0.640 | 0.760 | 0.840 | 0.920 | 1.000 | 1.080 | 1.160 | 1.240 | 1.320 |
| PF | 0.800 | 0.850 | 0.900 | 0.950 | 1.000 | 1.050 | 1.100 | 1.150 | 1.200 |
Values below 1.000 suppress the event below neutral; values above 1.000 boost it. The 3B scale is dramatically steeper than the others: a −4 3B park suppresses triples to 40% of neutral, while a +4 park nearly doubles them to 1.70×.
Where in the decision tree each modifier applies
The sim resolves each plate appearance in sequence. Understanding where the modifiers enter matters:
Step 1 — BB or no BB. Base on balls probability is resolved first. Fatigue is applied to the batter’s RL stats for hitters and for pitchers at the very start, which trickles down and impacts all downstream rate stats. No park modifier applies at this step.
Step 2 — Hit or out. The log5 formula is run on the batter’s AVG vs. the pitcher’s OAV against the league average, producing a raw H/AB probability. The 1B park modifier is then applied as a multiplier to this result. This is the first park effect that enters. The label “1B” is misleading, this modifier applies to all hits, not just singles, as it is applied to AVG/OAV.
Step 3 — HR or non-HR. If it’s a hit, the same log5 structure is applied to batter HR/H vs. pitcher HR/H, and the HR/LF or HR/RF modifier is applied (the sim assumes pull for batters; lefthand batters see HR/RF, righthand batters see HR/LF).
Step 4 — 3B or non-3B, 2B or non-2B. If not a HR, the sim works through triples and doubles in sequence using the same log5 structure, with 3B and 2B modifiers applied respectively. Any hit that isn’t a HR, 3B, or 2B becomes a single. (with one note, after the HR or non-HR is determined, before it looks at 3B/non-3B, it factors in the fielders RRF to determine if a hit is taken away by a “+” defensive play, then moves to 3B, 2B, etc.)
Key finding that resolved a community-wide misconception
The “1B” park rating applies to overall hit rate, not singles specifically. It affects the probability that any ball in play becomes a hit. Singles are the residual, whatever isn’t a HR, 3B, or 2B.
Handedness adjustment
Same-handed batter/pitcher matchups receive an additional 4.5% hit penalty applied against the end result of the H/AB calculation. This applies after the log5 and park modifier have been calculated, not before.
Fatigue application
As noted above, fatigue is applied at the very beginning of the equation against the pitcher’s RL stats and the batter’s RL stats before any other calculation runs. By applying it there, fatigue impacts all downstream rate stats: it reduces or boosts the base probability that propagates through the BB/PA, HR/H, 3B/H, and 2B/H calculations as well. This is why fatigued pitchers see their HR rate, ERA, and all other outputs degrade simultaneously rather than just their hit rate.
The Park Factor display number
A frequently asked question: why do two parks with the same 1B/2B/3B/HR/HR ratings display different Park Factors? The answer is that the UI rating is a bucket, and parks with the same integer rating may have different actual modifiers within that bucket’s range (one might be at the low end of the +1 range (1.046) and another at the high end (just below 1.092)). The Park Factor reflects the combined run-scoring environment of all those modifiers. It’s a useful summary but tells you nothing specific about which event types are being affected or by how much.
Part 2: worked example — 2000 Pedro Martínez vs. 1923 Babe Ruth
Using the same matchup Paul Bessire used in his presentation, then adding the park modifier layer:
Step 1: base H/AB via log5
H/AB = ((AVG × OAV) / LgAVG) / ((AVG × OAV) / LgAVG + (1 − AVG) × (1 − OAV) / (1 − LgAVG))
Where LgAVG = (Pedro's LgAVG + Ruth's LgAVG) / 2 = (.283 + .276) / 2 = .2791
H/AB = ((.393 × .167) / .2791) / ((.393 × .167) / .2791 + (1 − .393) × (1 − .167) / (1 − .2791))
Log5 result = .2504 — expected AVG for Ruth / OAV for Pedro in this matchup.Step 2: apply 1B (hit) park modifier
In Coors (+3): .2504 × 1.182 = .2959
In Petco (−3, low-range): .2504 × 0.862 = .2158Step 3: HR/H — base rate via log5
LgHR/H = (Ruth's .124 + Pedro's .037) / 2 = .0805
HR/H = ((.200 × .132) / .0805) / ((.200 × .132) / .0805 + (1 − .200) × (1 − .132) / (1 − .0805))
Log5 result = .3027 — roughly 30% of hits Ruth gets off Pedro will be HRs in a neutral park.Step 4: apply HR park modifier and calculate HR/AB
Coors (+3, HR/RF for left-handed Ruth):
.3027 × 1.374 = .4159 → HR/AB = .2959 × .4159 = .1230
Petco (−3, (mid-range) 0.700):
.3027 × 0.700 = .2118 → HR/AB = .2158 × .2118 = .0457| Park | H/AB (log5 base) | Hit modifier | Adjusted H/AB | HR/H base | HR modifier | Adj. HR/H | HR/AB |
|---|---|---|---|---|---|---|---|
| Neutral | 0.2504 | 1.000 | 0.2504 | 0.3027 | 1.000 | 0.3027 | 0.0758 |
| Coors (+3 HR/RF) | 0.2504 | 1.182 | 0.2959 | 0.3027 | 1.374 | 0.4159 | 0.1230 |
| Petco est. (−3) | 0.2504 | 0.862–0.908 | 0.2158 (worst) | 0.3027 | ~0.700 | 0.2118 | 0.0457 |
That’s 12.30 HR per 100 AB in Coors vs. 4.57 HR per 100 AB in Petco for this matchup, a factor of 2.7× difference, driven entirely by the park, for the same player matchup. The neutral-park rate is 7.58 HR per 100 AB.
Part 3: the ballpark effects calculator
The calculator is published as a Google Sheet: Ballpark Effects Calculator
What it does
A team manager selects their home ballpark from a dropdown. The sheet pulls in weighted park effect modifiers for that park — both a historical average (across all weeks of data) and a rolling recent average (100-day window). The calculator models a team’s season as half games at home (chosen park) and half on the road (weighted average of all parks in the league). Below the park selector, the manager enters their players, and the sheet outputs expected performance stats for each player given the park environment their team will experience.
Tool architecture
| Component | Description |
|---|---|
| Sheet: Roster / Calculator | The user-facing layer. A ballpark selector dropdown loads weighted park effect modifiers for a home park — both historical average and rolling 100-day recent average. Hitter and pitcher inputs below show expected output stats for the chosen park environment. Team games are modeled as half home (chosen park) and half road (league-average weighted effects). |
| Sheet: Ballparks | 134 parks with raw UI ratings (−4 to +4) for 1B, 2B, 3B, HR LF, HR RF plus Park Factor. Each row also carries the translated modifiers from the confirmed data points. The modifier scale is embedded in the sheet for reference. |
| Sheet: Ballpark History Dump | 873 weeks of WIS-published ballpark usage stats (1B, 2B, 3B, HR, HR LF, HR RF modifiers), collected via automated Apps Script. Rolling averages are calculated from the most recent weeks of this data. |
| Sheet: SeasonLeague Stats | Complete league-level hitting stats from 1885–2023 across all major leagues (NL, AL, AA, FL, PL, etc.) — 139 seasons. Used for normalization: converting raw player stats to league-adjusted # metrics that account for era effects. |
| Sheet: HitterSalaries | 53,596 hitter seasons with normalized batting stats (BA#, OBP#, SLG#, OPS#), hit-type rate breakdowns (HR/H, 3B/H, 2B/H), speed ratings, fielding grades, and WIS salary data. Powers the player lookup in the calculator. |
| Sheet: PitcherSalaries | 39,415 pitcher seasons with normalized pitching stats (ERA#, ERC#, OAV#, WHIP#), hit-type rate breakdowns, K/9, BB rates, fielding grades at P position, and WIS salary data. |
Data scale
| Dimension | Count |
|---|---|
| Ballparks catalogued | 134 |
| Hitter seasons | 53,596 |
| Pitcher seasons | 39,415 |
| League-season normalizations | 139 seasons (1885–2023) |
| Weeks of ballpark history | 873 weeks |
| Ballpark history data points | ~5,200 weekly modifier readings |
Apps Script: automated ballpark history collection
Three scripts handle the data collection and averaging:
pullAndInsertNextWeekData() — weekly data fetcher. Finds the last entered week number, increments it, fetches the new week’s ballpark modifier data from the WIS stat snapshot endpoint, and inserts it into the history dump sheet:
function pullAndInsertNextWeekData() {
var sheet = ss.getSheetByName('Ballpark History Dump');
// Find last entered week, increment, build URL
var url = `https://www.whatifsports.com/mlb-l/stat_snapshot.asp?rid=${newWeekNumber}`;
var htmlContent = UrlFetchApp.fetch(url).getContentText();
var tableData = parseHtmlToTableData(htmlContent);
sheet.getRange(lastRow + 3, 2, 1, tableData.length).setValues([tableData]);
}parseHtmlToTableData() — HTML parser. Extracts the modifier row from the WIS HTML table using regex, returning the six float values (1B, 2B, 3B, HR, HR LF, HR RF):
function parseHtmlToTableData(htmlContent) {
var pattern = /<tr class="odd">\s*<td>(.*?)<\/td>.../;
var matches = pattern.exec(htmlContent);
return matches.slice(1).map(value => parseFloat(value));
}calculateRecentAverages() — rolling average calculator. Calculates the average modifier across the 20 most recent weeks of data for each of the six event types and writes them to the summary row used by the Roster sheet:
function calculateRecentAverages() {
var startRow = sheet.getLastRow() - (20 * 3) + 1; // last 20 weeks
for (var col = 2; col <= 7; col++) {
// sum valid numeric values, write average to summary row
}