Debut K-Rate and Fantasy Viability: A Study

Our esteemed RotoGraphs editor recently passed along a question to the team that came to him from a reader:
“If a player runs a high k-rate upon debut for a significant amount of time, how likely is it that they’ll ever be a viable fantasy hitter?”
While I may have gone a bit overboard in doing so, the analysis below endeavors to find an answer to this question. I have not broken new analytical ground regarding the overall notion (TL;DR: strikeouts bad), but the extent to which k-rate impacts the likelihood to be even minimally fantasy viable is both stark and robust. If you are looking for a quick heuristic about whether or not you can move on from a prospect after they spend some time in the majors, you might have found it.
Data:
To conduct this analysis, I used Retrosheet data for play-by-play outcomes dating back to 1996 to calculate a player’s k-rate over a given period of his first plate appearances. I also exported dollar values from the FanGraphs Auction Calculator over the same time range. I conducted my analysis in R, and used my Mac’s command terminal to scrape the Retrosheet data and parse the event codes [1]. Once I had my data, I began setting about the contours of the analysis.
Framework:
To answer this question, I built a series of logistic regression models. I won’t bore you with the gory details, but the basic question a logistic regression helps answer (after some math) is the impact on the likelihood of an event occurring given certain inputs. In our case, the event (dependent variable, or regressand) is whether a player was ever fantasy viable. The input (independent variable, or regressor) is k-rate upon debut. I designed the models in three basic categories: one using a continuous regressor, one using categorical buckets for k-rates in increments of 5% as the regressor, and one using a binary yes/no indicator for whether or not the player’s initial k-rate exceeded 35%. Using each of these frameworks, I analyzed three different plate appearance samples: 30, 60, and 120 (60 being the point at which K-rates reasonably stabilize, 30 and 120 being half and double that). I will explain each of these models in greater detail along with the intuition of the results.
Assumptions:
There are two major branches of assumptions involved here: sample period, and the definition of what constitutes “fantasy viable.” For the latter, I chose players based on a default 5×5 roto with 12 teams, a $260 budget, and 23-player rosters. Using these settings, I considered players who had at least one season in their careers which met four separate value thresholds: $1, $5, $10, and $20. In other words, I identified players who all had a season worth at least $1. Then, $5. And so on. That the lowest end of this scale is not a high bar to clear is by design – in our case, using a blunt tool helps illustrate the point cleanly. Yes, the results of an analysis like this could naturally vary somewhat based on the scoring settings you choose. Players who strike out more will likely fare worse in points leagues whose settings penalize strikeouts. Likewise, the impact may be muted in leagues which reward traits that tend to be correlated with strikeouts such as high walk rates and more power. But overall, given that 5×5 12-team roto is like the vanilla ice cream of fantasy baseball, I think the results are, much like vanilla ice cream, broadly generalizable.
For my sample period, I chose data from 1996-2025 [2] because it would give me an even 30 years of outcome data. Now, you may look at that sample period and think “Ah – but the k-rates have been rising since then! Surely that will impact your results.” And you’d be right.
To this, I have an answer: controlling for each year. Year controls introduce each year as a regressor in the model and theoretically strip the impact of that year from the results on the regressors we care about. Now, in order for these controls to work, I needed to make additional choices. I had to choose a cutoff date for debutants because in order for my year controls to not account for the impact of truncation (i.e., the notion that a player who debuted in 2025 has simply had fewer bites at the apple), I needed to go far enough back that players had a reasonable chance to accrue a viable season. Had I not, the year control would be, in effect, penalizing players who debuted in later years.
To determine a reasonable cutoff, I looked at how long it typically took a player to have a viable fantasy season:

For all players who had a season viable at the $1 level, roughly 95% of them did so by the end of the sixth season after their debut. Based on this, I limited my sample to players who had debuted no later than 2019, meaning all players had reached at least six years post-debut by 2025, the latest full season available in my sample. I like this for two reasons: one is that six seasons post debut represents roughly two standard deviations above the mean in this sample of data. The second is that six years post debut lines up neatly with current service time rules. In the analysis realm, you don’t always get a marriage of rock-solid quantitative and qualitative rationales, so you have to take advantage of the opportunities when they arise.
With this in mind, I’ll next move on to the models and how to frame their results. For each, I’m going to present results from the 60 plate appearance sample. The results across each sample are broadly similar, and I’ll discuss a few noteworthy differences as they occur [3].
Continuous K-Rates:
The graph below models the probability of being fantasy viable for every point along the k-rate spectrum between 5% and 50%. This helps verify our basic suspicion: as k-rates increase, a player becomes less likely to ever register a viable season.

Notably, given the curved slopes, the effect isn’t constant across the spectrum of k-rates. In the graph below, the y-axis depicts the incremental impact on the probability of being viable based on where a player is on the k-rate spectrum. Effects are more pronounced earlier on the x-axis – the incremental impact of striking out 40% of the time relative to 39% is less damaging than, say, the impact of striking out 30% vs. 29%.

One potential flaw in the continuous model, though, is that any extent to which the relationship is weaker on one side of the distribution than the other is obscured. In other words, there is a chance that k-rates don’t matter statistically until they reach a certain threshold, at which point they become very important. This will create an “averaging out” effect, where a weaker impact earlier in the spectrum is overstated because of a strong impact at the end, and vice versa.
K-Rate Buckets:
To solve this problem, I examined how likely a player is, given their place in a certain k-rate bucket, to ever be fantasy viable. My buckets start at zero and increase in increments of five percent up to the maximum in the sample. This allows me to both climb the ladder of k-rates like the continuous analysis above and also isolate whether or not probabilities at specific k-rates are statistically significant. A brief math note here: because of how these regressions work, I had to choose a “baseline” category. This matters in how the equation for converting the results to probabilities is set up but is otherwise not influencing the actual probabilities themselves. I chose the 5-10% bucket because it had relatively higher incidences of viability in the sample and can therefore be construed in this sense to be our “best-performing” bucket.
In the table below, I have omitted displaying the results for the buckets on the very high end of the spectrum; the sample sizes are quite small and the results don’t provide meaningful information. I have also omitted the $20 performance threshold because few of the outcomes are statistically significant. They are, though, directionally similar to the other viability thresholds.
Each column demonstrates the likelihood for a player in the given k-rate bucket to ever be fantasy viable at the various thresholds. For example, a player in the 30-35% bucket is only 22.8% likely to be viable at the $5 level, according to my sample.
| Bucket | $1+ | vs. Prior | $5+ | vs. Prior | $10+ | vs. Prior |
|---|---|---|---|---|---|---|
| 0-5% | 53.3% | — | 48.5% | — | 35.8% | — |
| 5-10% | 60.4% | +13.3% | 50.6% | +4.5% | 40.8% | +14% |
| 10-15% | 50.4% | −16.6% | 48.6% | −4.1% | 38.5% | −5.5% |
| 15-20% | 42.9%** | −14.9% | 39.6% | −18.6% | 31.5% | −18.2% |
| 20-25% | 42.6%** | −0.6% | 41.5% | +4.8% | 32.9% | +4.4% |
| 25-30% | 31.7%*** | −25.6% | 30.2%*** | −27.1% | 23.3%** | −29.2% |
| 30-35% | 23.5%*** | −25.8% | 22.8%*** | −24.5% | 15.6%*** | −33.2% |
| 35-40% | 13.4%*** | −43.1% | 15.3%*** | −32.8% | 11.8%*** | −24% |
| 40-45% | 17.8%*** | +32.6% | 17.7%*** | +15.2% | 16.2%** | +36.9% |
This table is information dense, and not every result is statistically significant, so I’ll highlight a couple patterns. First: at the $1 threshold, the results aren’t significant until the 15-20% bucket. At $5 and $10 viability, results aren’t significant until the 25-30% bucket. This means we can say that up to each of those levels, a player’s likelihood of viability is not materially different from the baseline k-rate bucket of 5-10%. Once we cross the 25% threshold though, we see clear decreases with a nadir at 35-40% for each measure of viability.
Interestingly, players in the 40-45% bucket fare slightly better than the bucket prior. This pattern is not present in either of the other samples I tested. While this may just be statistical noise, perhaps 60 plate appearances is a threshold at which players with certain power-heavy profiles struggle, but ultimately recover. Meanwhile 30 plate appearances is not enough of a sample to detect this dynamic, and players who are still striking out greater than 40% of the time after 120 plate appearances are a much less hopeful case. Notably, the 30-plate appearance sample exhibits similar behavior at the 30-35% bucket relative to the 25-30% bucket, with statistically significant results. This pattern is certainly worth future exploration and could be the subject of an article of its own.
Over/Under 35% K-Rate
In search of a pithy takeaway for this analysis, I wanted a point at which we can be relatively certain that a player’s chances at being viable are cooked. Informed by the more granular bucket groupings above, I separated players into groups above and below a 35% k-rate.
| Group | $1+ | $5+ | $10+ | $20+ |
|---|---|---|---|---|
| < 35% | 38.5% | 36.9% | 28.4% | 18.0% |
| >= 35% | 12.5% | 13.3% | 10.7% | 5.9% |
This model setup is where the results are most stark. Based on these results, it simply does not take very long to ascertain whether a player will ever be worth rostering. Players who strike out more than 35% of the time in their first 60 plate appearances are no more than 13% likely to be viable by any standard, and are roughly two-to-three times less likely to be viable than players striking out at rates below the cutoff.
Allow me take a brief detour that is too large for a footnote, and that comes with a math warning: an astute reader may notice that the $5 viability probabilities are actually higher than for $1 viability. Likewise, the lines in the continuous model for $1 and $5 viabilities eventually cross at k-rates in the 30s. These each imply the same thing: that it is somewhat likelier for a high k-rate player to be $5 valuable than it is for them to be $1 valuable. Logically, this isn’t possible; a $5 viable season must, by definition, be a $1 viable season. So a $1 viable season must be at least as likely as the $5 viable season. I don’t think this result is necessarily a problem; I checked the data and the number of $5 viable seasons in the sample is indeed lower than $1 viable for k-rates above 35%. Most likely, the year effects are interacting with the target variable slightly differently, and because these models are estimated independently from each other and do not enforce this nuance, we get a bit of weirdness in the results. As you’ll see in the Discussion, there is a lot of overlap in the confidence intervals for these viabilities. So, in short, I suspect what we’re seeing here is some amount of statistical noise, but it is worth noting. That there does not appear to be a statistically discernible difference in a player’s odds to be $1 viable vs. $5 viable after a high k-rate is an interesting finding on its own and worth further exploration.
Back to your regular programming: these odds are especially damning when considering the likelihood to become an elite fantasy player ($20 or more). Only 25 players in the sample struck out greater than 35% of the time after 60 plate appearances and went on to post a $20 or better season:
| Player | K-Rate | Debut Year |
|---|---|---|
| Domingo Santana | 51.7% | 2014 |
| Paul Goldschmidt | 43.3% | 2011 |
| Aaron Judge | 43.3% | 2016 |
| Shea Langeliers | 43.3% | 2022 |
| Julio Rodríguez | 41.7% | 2022 |
| Christian Walker | 41.7% | 2014 |
| Willy Adames | 40.0% | 2018 |
| José Bautista | 40.0% | 2004 |
| Jarren Duran | 40.0% | 2021 |
| Derrek Lee | 40.0% | 1997 |
| Tyler O’Neill | 40.0% | 2018 |
| Giancarlo Stanton | 40.0% | 2010 |
| Trevor Story | 40.0% | 2016 |
| Javier Báez | 38.3% | 2014 |
| Matt Chapman | 38.3% | 2017 |
| Adolis García | 38.3% | 2018 |
| Nick Kurtz | 38.3% | 2025 |
| Josh Lowe | 36.7% | 2021 |
| Brent Rooker | 36.7% | 2020 |
| Pat Burrell | 35.0% | 2000 |
| Josh Donaldson | 35.0% | 2010 |
| Brenton Doyle | 35.0% | 2023 |
| Austin Riley | 35.0% | 2019 |
| Jonathan Villar | 35.0% | 2013 |
| Preston Wilson | 35.0% | 1998 |
One could look at this list and be somewhat optimistic; there are a few Hall of Fame and several Hall of Very Good careers. But to this point, I caution about selection bias – we are looking at players who posted $20 fantasy seasons. Of course they’re good. These players are the outlier, not the norm.
Discussion
To validate the results, I wanted to look at the eventual performance of high-strikeout post-2020 debutants (n = 89) to see how frequently they achieved viability at various levels. Below is a table showing the expected results for an 89-player sample along with the 95% confidence intervals. We should expect the number of players who debuted with greater than 35% k-rates to achieve viability to fall between the intervals at each threshold.
| Viability Threshold | Low | Mean | High |
|---|---|---|---|
| $1+ | 8.1 | 11.1 | 14.8 |
| $5+ | 8.5 | 11.8 | 15.9 |
| $10+ | 6.6 | 9.5 | 13.1 |
| $20+ | 3.2 | 5.3 | 8.1 |
This comparison isn’t perfect – because of the truncation problem mentioned before, we are likely undercounting the eventual number of viable seasons from this group of players. Likewise, any year-specific environmental effect (hello, Covid-19) is not accounted for. That said:
| Viability Threshold | n | Mean Expected |
|---|---|---|
| $1+ | 17 | 11.1 |
| $5+ | 11 | 11.8 |
| $10+ | 9 | 9.5 |
| $20+ | 7 | 5.3 |
Pretty good! We have slightly fewer $5- and $10-viable players than the mean estimate, which is aligned with the intuition discussed above. We do have more $20-viable players than the mean estimate, but still within the upper bound. The small sample size of players at that level make precise interpretation difficult, but the results within these viability thresholds are overall encouraging and lend, in my opinion, a notion of reasonableness to the results.
Where we have some issue, though, is the $1 viability threshold. Our number of viable players actually exceeds the upper bound, which runs counter to the caveats discussed above. Why would this be occurring?
I’m not sure. I suspect it may be a combination of several interacting factors: $1 as a potentially weak indicator of value, Covid weirdness, and overall k-rate environment. Let’s consider the latter two first. Everyone knows the overall strikeout environment is increasing (or, at least, is elevated post-2020):

Explicitly, our model is missing this because it’s not incorporating data from these years. All else equal, as k-rates increase, the number of fantasy viable seasons at higher k-rates also increases.
Next, debutant data around the Covid-19 pandemic is, for lack of a better word, weird.
| Season | Debutants | >= 35% K-Rates |
|---|---|---|
| 1996 | 100 | 11 |
| 1997 | 87 | 15 |
| 1998 | 107 | 13 |
| 1999 | 89 | 14 |
| 2000 | 84 | 17 |
| 2001 | 100 | 16 |
| 2002 | 85 | 12 |
| 2003 | 78 | 13 |
| 2004 | 89 | 12 |
| 2005 | 99 | 15 |
| 2006 | 94 | 19 |
| 2007 | 84 | 12 |
| 2008 | 113 | 10 |
| 2009 | 79 | 9 |
| 2010 | 94 | 17 |
| 2011 | 112 | 18 |
| 2012 | 88 | 19 |
| 2013 | 104 | 13 |
| 2014 | 99 | 15 |
| 2015 | 98 | 24 |
| 2016 | 110 | 25 |
| 2017 | 102 | 28 |
| 2018 | 91 | 23 |
| 2019 | 102 | 28 |
| 2020 | 67 | 15 |
| 2021 | 73 | 15 |
| 2022 | 120 | 16 |
| 2023 | 93 | 13 |
| 2024 | 82 | 17 |
| 2025 | 69 | 13 |
The 2020 season saw by far the highest number of debutant hitters as a percentage of games played. In addition to this, there could be a selection bias in the group of players who were debuting. Teams may have been selective about which players they promoted, and some players simply skipped the season outright. If the type of prospect who did not play in 2020 was on average more talented than one who did, this will dilute the talent pool more than a typical season of debutants would. In 2021, the number of debutants was the second-lowest in the sample (excluding 2020), which could point to teams continuing to be circumspect with their prospects. Then, in 2022, we saw the highest raw number of debutants in the sample. These players could likewise have had their development negatively impacted by the pandemic. This could create a sort of expansionary effect if we assume the debuting players are generally worse than established players, or, at least worse than they otherwise would have been.
This combination of factors could lead to a situation where objectively worse seasons become fantasy viable relative to the same stat line in another year’s environment. This is where the $1 threshold can be especially sensitive. The difference between a $1 player and nonviability is thin, especially compared to $5 or $10 dollar players. Perhaps the overall bar, between the player pool and k-rate environment, for a viable player is simply lower so more players cleared it [4]. It could be that performance needed to reach $5 or $10 is more objectively durable and therefore able to manifest in a manner aligned with expectation despite our flawed sample.
In any case, I do think the evidence from recent years points to real signal regarding the overall notion of the results. It is clear that players striking out at high rates, particularly over 35%, are not especially likely to register a fantasy viable season. With this in mind, I want to look specifically at some of the most recent strikeout-prolific debutants. These are players for whom owners may still be waiting on a breakout. Based on my results, I would caution against getting your hopes up:
| Player | K-Rate | Debut Year |
|---|---|---|
| Colby Thomas | 43.3% | 2025 |
| Jackson Holliday | 41.7% | 2024 |
| Niko Kavadas | 40.0% | 2024 |
| Justyn-Henry Malloy | 40.0% | 2024 |
| Wade Meckler | 40.0% | 2023 |
| Jacob Melton | 40.0% | 2025 |
| Everson Pereira | 40.0% | 2023 |
| Will Wilson | 40.0% | 2025 |
| Jordyn Adams | 38.3% | 2023 |
| Jordan Beck | 38.3% | 2024 |
| Alexander Canario | 38.3% | 2023 |
| Blake Dunn | 38.3% | 2024 |
| Tristan Gray | 38.3% | 2023 |
| Nick Kurtz | 38.3% | 2025 |
| Jordan Lawlar | 38.3% | 2023 |
| Tyler Locklear | 38.3% | 2024 |
| Ryan Ritter | 38.3% | 2025 |
| Johnathan Rodríguez | 38.3% | 2024 |
| Carson Williams | 38.3% | 2025 |
| Luken Baker | 36.7% | 2023 |
| Dillon Dingler | 36.7% | 2024 |
| Oliver Dunn | 36.7% | 2024 |
| Braxton Fulford | 36.7% | 2025 |
| Edouard Julien | 36.7% | 2023 |
| Joey Loperfido | 36.7% | 2024 |
| Matthew Lugo | 36.7% | 2025 |
| Jhonkensy Noel | 36.7% | 2024 |
| Graham Pauley | 36.7% | 2024 |
| Will Robertson | 36.7% | 2025 |
| Nick Sogard | 36.7% | 2024 |
| Bryce Teodosio | 36.7% | 2024 |
| Jacob Amaya | 35.0% | 2023 |
| Blaine Crim | 35.0% | 2025 |
| Zach Dezenzo | 35.0% | 2024 |
| Brenton Doyle | 35.0% | 2023 |
| Tim Elko | 35.0% | 2025 |
| Mason McCoy | 35.0% | 2023 |
Now, there are a lot of players here who we don’t expect to become fantasy viable. But there are a few who we do, or at least did. Jackson Holliday obviously stands out. Likewise, Nick Kurtz, Carson Williams, and Jordan Lawlar were all top prospects in our 2025 list. But if one of these players isn’t already producing at a high level, it might be tough to expect them to do so. Remember, the odds of a 2019 debutant being a $20 player after striking out this much were roughly 6%. If the odds hold across more recent years, in a sample of 43 players, we can expect 2-3 players capable of producing to that level. Turns out we’ve already had them: Brenton Doyle in 2024, Kurtz last year, and maybe Dillon Dingler this year. Other additions to this list wouldn’t be unheard of, but they certainly aren’t likely by any stretch. If you haven’t already, it might be time to cut bait on the other guys. Or, at least, abandon hope that you have a superstar waiting to break out.
Conclusion:
First, a note on an extreme outlier: I can’t believe I’ve made it this far without directly mentioning Aaron Judge. He is the dragon that anyone rostering players with high k-rates is chasing. He who weathered a 43% k-rate in his first 60 plate appearances, and 40% in his first 120, to become perhaps the best right-handed hitter of all-time. I get it, I really do. But he is a unicorn and I don’t recommend getting too hung up here.
That said, there are a few key takeaways from this analysis. The first is how quickly these results seem to stabilize. In each test, players striking out at high rates were much less likely to ever be viable after just 30 plate appearances. Next, our conventional understanding is borne out. K-rates below 25% seem relatively inconsequential, 25-35% should make you nervous, while 35% is a five-alarm fire. Above all, an investment in a player with a high k-rate – whether it’s a roster spot, auction budget, draft pick, whatever – is simply not likely to return very much value.
Special thanks to my good friend Dana Kayser who graciously agreed to peer review and comment on the statistics for a topic they emphatically do not care about.
Endnotes
[1] For transparency, I used Claude Sonnet 4.6 to help scrape the data and write the code for the analysis. I will dump my code in the comments. If you are really interested in the command terminal nonsense, leave a comment and I will do my best to reply.
[2] If you are a sicko like me whose brain is scratched by looking at player ratings on old sports video games, I highly recommend looking through past fantasy auction value results. If you select “2026 YTD” in the calculator, generate the projections, and then change “2026” in your URL, you can find the rankings for any year.
[3] One additional methodological note is that the depiction of odds from each model are for a player debuting in 2019. Specifying a year is necessary due to my year controls, because modeling a player who debuted in 1996 might have slightly different results due to the temporal impact of their debut year. I chose 2019 because, as the cutoff year, it is the most recent relative to present day and should be easiest for the reader to frame in their mind.
[4] Another digression that is not worth cluttering the main text: perhaps there was something weird with pitchers. This analysis only covers hitter value, which isn’t calculated in a vacuum relative to pitchers. This effect should get caught in the year effects of the specified model but would be missing from non-modeled years.
Jonathan is a contributor for RotoGraphs. He is a Tigers fan living in Philadelphia with his wife and dog and requests that you leave your best pizza topping combinations in the comments.
Awesome work, thank you!
Would maybe be interesting to stratify the result by position as well since the paths to fantasy viability is often different for a catcher than a right fielder. Great work though!
True, this is a great point. Would introduce a lot more things to account for but is an important nuance nonetheless.
this is an incredibly informative and well-done article – and A LOT of work. thank you! bookmarking it as a favorite for easy access to read and enjoy. we will be using it in drafting next year and FA pickups this year.
Data Scrape:
# ============================================================
# Retrosheet Data Collection
# Reads cwevent-parsed CSVs into R and combines into one
# cached dataframe for all seasons 1996-2025
# Run terminal steps in terminal_steps.docx before running this script
# ============================================================
library(readr)
library(dplyr)
library(baseballr)
# ── Column Names ─────────────────────────────────────────────
# cwevent outputs 97 columns (fields 0-96) with no header row
# These are the official Retrosheet/Chadwick column names in order
cw_col_names <- c(
“GAME_ID”, “AWAY_TEAM_ID”, “INN_CT”, “BAT_HOME_ID”, “OUTS_CT”,
“BALLS_CT”, “STRIKES_CT”, “PITCH_SEQ_TX”, “AWAY_SCORE_CT”,
“HOME_SCORE_CT”, “BAT_ID”, “BAT_HAND_CD”, “RESP_BAT_ID”,
“RESP_BAT_HAND_CD”, “PIT_ID”, “PIT_HAND_CD”, “RESP_PIT_ID”,
“RESP_PIT_HAND_CD”, “POS2_FLD_ID”, “POS3_FLD_ID”, “POS4_FLD_ID”,
“POS5_FLD_ID”, “POS6_FLD_ID”, “POS7_FLD_ID”, “POS8_FLD_ID”,
“POS9_FLD_ID”, “BASE1_RUN_ID”, “BASE2_RUN_ID”, “BASE3_RUN_ID”,
“EVENT_TX”, “LEADOFF_FL”, “PH_FL”, “BAT_FLD_CD”, “BAT_LINEUP_ID”,
“EVENT_CD”, “BAT_EVENT_FL”, “AB_FL”, “H_CD”, “SH_FL”, “SF_FL”,
“EVENT_OUTS_CT”, “DP_FL”, “TP_FL”, “RBI_CT”, “WP_FL”, “PB_FL”,
“FLD_CD”, “BATTEDBALL_CD”, “BUNT_FL”, “FOUL_FL”,
“BATTEDBALL_LOC_TX”, “ERR_CT”, “ERR1_FLD_CD”, “ERR1_CD”,
“ERR2_FLD_CD”, “ERR2_CD”, “ERR3_FLD_CD”, “ERR3_CD”,
“BAT_DEST_ID”, “RUN1_DEST_ID”, “RUN2_DEST_ID”, “RUN3_DEST_ID”,
“BAT_PLAY_TX”, “RUN1_PLAY_TX”, “RUN2_PLAY_TX”, “RUN3_PLAY_TX”,
“RUN1_SB_FL”, “RUN2_SB_FL”, “RUN3_SB_FL”, “RUN1_CS_FL”,
“RUN2_CS_FL”, “RUN3_CS_FL”, “RUN1_PK_FL”, “RUN2_PK_FL”,
“RUN3_PK_FL”, “RUN1_RESP_PIT_ID”, “RUN2_RESP_PIT_ID”,
“RUN3_RESP_PIT_ID”, “GAME_NEW_FL”, “GAME_END_FL”, “PR_RUN1_FL”,
“PR_RUN2_FL”, “PR_RUN3_FL”, “REMOVED_FOR_PR_RUN1_ID”,
“REMOVED_FOR_PR_RUN2_ID”, “REMOVED_FOR_PR_RUN3_ID”,
“REMOVED_FOR_PH_BAT_ID”, “REMOVED_FOR_PH_BAT_FLD_CD”,
“PO1_FLD_CD”, “PO2_FLD_CD”, “PO3_FLD_CD”, “ASS1_FLD_CD”,
“ASS2_FLD_CD”, “ASS3_FLD_CD”, “ASS4_FLD_CD”, “ASS5_FLD_CD”,
“EVENT_ID”
)
# ── Single Year Reader ────────────────────────────────────────
# Reads one season’s cwevent CSV from disk, assigns column names,
# reads everything as character to avoid type parsing errors,
# then converts only the columns needed for analysis
read_cwevent <- function(year) {
file <- sprintf(“~/retrosheet_raw/%d_events.csv”, year)
# ── File Existence Check ──────────────────────────────────
# Stops immediately with a clear message if the CSV is missing
# rather than letting read_csv throw a cryptic error
if (!file.exists(file)) {
stop(“Missing file: “, file)
}
cat(sprintf(“Reading %d…\n”, year))
read_csv(
file,
col_names = cw_col_names,
col_types = cols(.default = “c”),
show_col_types = FALSE
) %>%
mutate(
# ── Key Normalisation ─────────────────────────────────
# trimws() on BAT_ID mirrors the cleaning applied to
# key_retro in people_slim — both sides must match
BAT_ID = trimws(BAT_ID),
# ── Type Conversions ──────────────────────────────────
AB_FL = AB_FL == “T”,
H_CD = as.integer(H_CD),
SF_FL = SF_FL == “T”,
SH_FL = SH_FL == “T”,
BAT_EVENT_FL = BAT_EVENT_FL == “T”,
EVENT_CD = as.integer(EVENT_CD),
game_date = as.Date(substr(GAME_ID, 4, 11), format = “%Y%m%d”),
season = year
) %>%
# ── Post-Conversion Checks ────────────────────────────────
# These stop execution if something unexpected came through
# in the CSV rather than letting bad data flow downstream
{ stopifnot(“NA values in EVENT_CD” = sum(is.na(.$EVENT_CD)) == 0); . } %>%
{ stopifnot(“NA values in H_CD” = sum(is.na(.$H_CD)) == 0); . } %>%
{ stopifnot(“NA values in game_date” = sum(is.na(.$game_date)) == 0); . }
}
# ── Player Biographical Data ──────────────────────────────────
# Chadwick lookup table links player IDs across data sources
# key_retro links to our Retrosheet BAT_ID column
# key_bbref and key_fangraphs allow joining to other sources
# mlb_played_first is used to identify debutants by year
people <- chadwick_player_lu()
people_slim <- people %>%
select(key_mlbam, key_retro, key_bbref, key_fangraphs,
name_last, name_first, mlb_played_first) %>%
mutate(key_retro = trimws(key_retro)) %>%
filter(key_retro != “” & !is.na(key_retro))
# ── Join Quality Checks ───────────────────────────────────────
# Run before the main load to catch Chadwick issues early
# Duplicate key_retro values would cause silent row duplication
dup_retro <- people_slim %>%
count(key_retro) %>%
filter(n > 1)
if (nrow(dup_retro) > 0) {
warning(sprintf(
“%d duplicate key_retro values in Chadwick — join may duplicate rows”,
nrow(dup_retro)
))
print(dup_retro)
}
# ── Cache Check ───────────────────────────────────────────────
# If the .rds already exists, load it and skip the full rebuild
# Delete the file manually to force a rebuild when needed
cache_file <- “~/retrosheet_raw/all_events_cw.rds”
if (file.exists(cache_file)) {
cat(“Cache found — loading from disk\n”)
all_events_cw <- readRDS(cache_file)
cat(sprintf(“Loaded %s rows\n”, format(nrow(all_events_cw), big.mark = “,”)))
} else {
# ── Multi-Season Load ───────────────────────────────────────
# Reads each season, joins biographical data per year, then
# combines — avoids building a gigantic table before joining
all_events_cw <- bind_rows(lapply(1996:2025, function(yr) {
read_cwevent(yr) %>%
left_join(people_slim, by = c(“BAT_ID” = “key_retro”))
}))
cat(sprintf(“Total rows: %s\n”, format(nrow(all_events_cw), big.mark = “,”)))
# ── Post-Join Validation ──────────────────────────────────
# Flags what proportion of batting events have no Chadwick match
# A high pct_missing may indicate a key_retro cleaning problem
pct_missing <- mean(is.na(all_events_cw$key_mlbam))
cat(sprintf(“Unmatched BAT_ID: %.2f%%\n”, pct_missing * 100))
if (pct_missing > 0.01) {
warning(“More than 1% of rows failed to join — investigate before proceeding”)
}
# ── Cache to Disk ─────────────────────────────────────────
saveRDS(all_events_cw, cache_file)
cat(sprintf(“Saved to %s\n”, cache_file))
}
Data Manipulation:
# ══════════════════════════════════════════════════════════════════════════════
# ANALYSIS SCRIPT — K Rate Debut Study
# Input: all_events_cw (from data collection script), FanGraphs CSVs
# Output: leaderboard_30, leaderboard_60, leaderboard_120
# ══════════════════════════════════════════════════════════════════════════════
library(fastDummies)
library(dplyr)
library(tidyr)
library(readr)
library(gt)
library(ggplot2)
# ── Rename scrape output to retro ─────────────────────────────────────────────
retro <- all_events_cw
# ── Type coercions ────────────────────────────────────────────────────────────
# Only converting columns not already handled in read_cwevent
# H_CD, EVENT_CD, AB_FL, BAT_EVENT_FL, SH_FL, SF_FL are already
# converted in the collection script — these are the remaining ones
retro <- retro |>
mutate(
INN_CT = as.integer(INN_CT),
OUTS_CT = as.integer(OUTS_CT),
EVENT_OUTS_CT = as.integer(EVENT_OUTS_CT),
RBI_CT = as.integer(RBI_CT)
)
# ── Leaderboard function ───────────────────────────────────────────────────────
# No debut year cutoff applied here — all debutants are included
# so post-2019 players are preserved for descriptive use
#
# Arguments:
# data — the full retro play-by-play dataframe
# pa_threshold — minimum PAs required (default: 60)
# debut_year — earliest debut cohort to include (default: 1996)
# max_debut_year — latest debut cohort to include (default: 9999 = no cutoff)
debut_kpct_leaderboard <- function(data, pa_threshold = 60, debut_year = 1996, max_debut_year = 9999) {
data |>
filter(BAT_EVENT_FL == TRUE) |>
filter(mlb_played_first >= debut_year) |>
filter(mlb_played_first <= max_debut_year) |>
arrange(BAT_ID, game_date, EVENT_ID) |>
group_by(BAT_ID) |>
mutate(pa_num = row_number()) |>
filter(max(pa_num) >= pa_threshold) |>
filter(pa_num <= pa_threshold) |>
ungroup() |>
group_by(BAT_ID, key_mlbam, key_bbref, key_fangraphs,
name_first, name_last) |>
summarise(
PA = n(),
K = sum(EVENT_CD == 3),
K_pct = K / PA,
.groups = “drop”
) |>
arrange(K_pct)
}
# ══════════════════════════════════════════════════════════════════════════════
# FANGRAPHS AUCTION DATA
# ══════════════════════════════════════════════════════════════════════════════
fg_path <- “/Users/jvl/Desktop/RotoGraphs/K Rate Cutoffs/Analysis Workflow/Raw Data/Auction Calculator Data”
# ── Directory existence check ─────────────────────────────────────────────────
if (!dir.exists(fg_path)) {
stop(“FanGraphs data directory not found: “, fg_path)
}
fg_files <- list.files(fg_path, pattern = “values_\\d{4}\\.csv”, full.names = TRUE)
if (length(fg_files) == 0) {
stop(“No FanGraphs CSV files found in: “, fg_path)
}
fg_raw <- lapply(fg_files, function(f) {
year <- as.integer(gsub(“.*values_(\\d{4})\\.csv”, “\\1”, f))
df <- read_csv(f, show_col_types = FALSE)
df$season <- year
df
})
fg_all <- bind_rows(fg_raw)
fg_all <- fg_all |>
mutate(
viable_1 = as.integer(Dollars >= 1),
viable_5 = as.integer(Dollars >= 5),
viable_10 = as.integer(Dollars >= 10),
viable_20 = as.integer(Dollars >= 20)
)
fg_collapsed <- fg_all |>
group_by(PlayerId) |>
summarise(
ever_viable_1 = max(viable_1),
ever_viable_5 = max(viable_5),
ever_viable_10 = max(viable_10),
ever_viable_20 = max(viable_20),
.groups = “drop”
)
# ── Extract debut year per player from Chadwick field ─────────────────────────
# min() used instead of first() to avoid row-order dependency
debut_years <- retro |>
group_by(BAT_ID) |>
summarise(debut_year = min(mlb_played_first, na.rm = TRUE), .groups = “drop”)
# ══════════════════════════════════════════════════════════════════════════════
# LEADERBOARD PREP FUNCTION
# Joins viability flags, debut year, and K% buckets
# No cohort filtering — all debutants preserved
# ══════════════════════════════════════════════════════════════════════════════
prep_leaderboard <- function(lb) {
lb <- lb |>
left_join(fg_collapsed, by = c(“key_fangraphs” = “PlayerId”)) |>
left_join(debut_years, by = “BAT_ID”)
# ── FanGraphs join quality check ──────────────────────────────────────────
pct_missing_fg <- mean(is.na(lb$ever_viable_1))
cat(sprintf(“FanGraphs join — unmatched players: %.2f%%\n”, pct_missing_fg * 100))
if (pct_missing_fg > 0.05) {
warning(“More than 5% of players failed FanGraphs join — investigate before proceeding”)
}
# ── Relevel debut_year so 2019 is the reference category if present ───────
lb <- lb |>
mutate(debut_year = factor(debut_year)) |>
mutate(debut_year = if (“2019” %in% levels(debut_year))
relevel(debut_year, ref = “2019”) else debut_year)
# ── Assign descriptive K% buckets ───────────────────────────
# Used for descriptive analysis and display tables
breaks <- seq(0.00, 1.00, by = 0.05)
labels <- paste0(
formatC(breaks[-length(breaks)] * 100, width = 2, flag = “0”, format = “d”),
“_”,
formatC(breaks[-1] * 100, width = 2, flag = “0”, format = “d”)
)
lb <- lb |>
mutate(
k_bucket = cut(
K_pct,
breaks = breaks,
labels = labels,
right = FALSE
) |> relevel(ref = “05_10”),
high_k = as.integer(K_pct >= 0.35) # binary 35% cutoff flag
)
# ── K bucket coverage check ───────────────────────────────────────────────
n_outside <- sum(is.na(lb$k_bucket))
if (n_outside > 0) {
cat(sprintf(“K bucket — %d players outside 5%%–50%% range assigned NA (covered by full-range buckets in regression)\n”, n_outside))
}
lb
}
# ── Build and prep leaderboards ───────────────────────────────────────────────
leaderboard_30 <- prep_leaderboard(debut_kpct_leaderboard(retro, pa_threshold = 30))
leaderboard_60 <- prep_leaderboard(debut_kpct_leaderboard(retro))
leaderboard_120 <- prep_leaderboard(debut_kpct_leaderboard(retro, pa_threshold = 120))
# ── Confirm debut year distribution ───────────────────────────────────────────
leaderboard_60 |> count(debut_year)
Regressions and Subsequent Analyses:
# ══════════════════════════════════════════════════════════════════════════════
# STEP 1 — FIT MODELS
# Fits all models and stores raw prediction data
# Run once — outputs sit in environment for use by the display script
# ══════════════════════════════════════════════════════════════════════════════
fit_kpct_models <- function(lb) {
# ── Drop players with missing viability flags ──────────────────────────────
lb_model <- lb |>
filter(!is.na(ever_viable_1)) |>
filter(as.integer(as.character(debut_year)) <= 2019) |> # exclude post-2019
mutate(debut_year = droplevels(debut_year))
n_dropped <- nrow(lb) – nrow(lb_model)
cat(sprintf(“Regression — using %d players (%d dropped: missing viability flags)\n”,
nrow(lb_model), n_dropped))
# ── Build full-range K% buckets for regression ────────────────────────────
full_breaks <- seq(0, ceiling(max(lb_model$K_pct) * 20) / 20, by = 0.05)
full_labels <- paste0(
formatC(full_breaks[-length(full_breaks)] * 100, width = 2, flag = “0”, format = “d”),
“_”,
formatC(full_breaks[-1] * 100, width = 2, flag = “0”, format = “d”)
)
lb_model <- lb_model |>
mutate(
k_bucket_full = cut(
K_pct,
breaks = full_breaks,
labels = full_labels,
right = FALSE
) |> relevel(ref = “05_10”)
)
cat(“Full-range K bucket distribution:\n”)
print(table(lb_model$k_bucket_full, useNA = “always”))
# ── Fit continuous models ──────────────────────────────────────────────────
model_cont_1 <- glm(ever_viable_1 ~ K_pct + debut_year, data = lb_model, family = binomial)
model_cont_5 <- glm(ever_viable_5 ~ K_pct + debut_year, data = lb_model, family = binomial)
model_cont_10 <- glm(ever_viable_10 ~ K_pct + debut_year, data = lb_model, family = binomial)
model_cont_20 <- glm(ever_viable_20 ~ K_pct + debut_year, data = lb_model, family = binomial)
# ── Fit bucket models ─────────────────────────────────────────────────────
model_buck_1 <- glm(ever_viable_1 ~ k_bucket_full + debut_year, data = lb_model, family = binomial)
model_buck_5 <- glm(ever_viable_5 ~ k_bucket_full + debut_year, data = lb_model, family = binomial)
model_buck_10 <- glm(ever_viable_10 ~ k_bucket_full + debut_year, data = lb_model, family = binomial)
model_buck_20 <- glm(ever_viable_20 ~ k_bucket_full + debut_year, data = lb_model, family = binomial)
# ── Fit binary models ─────────────────────────────────────────────────────
model_hk_1 <- glm(ever_viable_1 ~ high_k + debut_year, data = lb_model, family = binomial)
model_hk_5 <- glm(ever_viable_5 ~ high_k + debut_year, data = lb_model, family = binomial)
model_hk_10 <- glm(ever_viable_10 ~ high_k + debut_year, data = lb_model, family = binomial)
model_hk_20 <- glm(ever_viable_20 ~ high_k + debut_year, data = lb_model, family = binomial)
# ── Continuous predictions averaged across all debut years ────────────────
k_seq <- data.frame(
K_pct = seq(0.05, 0.50, by = 0.01),
debut_year = factor(“2019”, levels = levels(lb_model$debut_year))
)
k_seq$prob_viable_1 <- predict(model_cont_1, newdata = k_seq, type = “response”)
k_seq$prob_viable_5 <- predict(model_cont_5, newdata = k_seq, type = “response”)
k_seq$prob_viable_10 <- predict(model_cont_10, newdata = k_seq, type = “response”)
k_seq$prob_viable_20 <- predict(model_cont_20, newdata = k_seq, type = “response”)
# ── Bucket predictions averaged across all debut years ────────────────────
bucket_seq <- data.frame(
k_bucket_full = factor(model_buck_1$xlevels$k_bucket_full,
levels = model_buck_1$xlevels$k_bucket_full),
debut_year = factor(“2019”, levels = levels(lb_model$debut_year))
)
bucket_seq$prob_viable_1 <- predict(model_buck_1, newdata = bucket_seq, type = “response”)
bucket_seq$prob_viable_5 <- predict(model_buck_5, newdata = bucket_seq, type = “response”)
bucket_seq$prob_viable_10 <- predict(model_buck_10, newdata = bucket_seq, type = “response”)
bucket_seq$prob_viable_20 <- predict(model_buck_20, newdata = bucket_seq, type = “response”)
bucket_seq <- bucket_seq |>
ungroup() |> # strip grouping before storing
rename(k_bucket = k_bucket_full)
# ── Binary predictions averaged across all debut years ────────────────────
hk_seq <- data.frame(
high_k = c(0, 1),
debut_year = factor(“2019”, levels = levels(lb_model$debut_year))
)
hk_seq$prob_viable_1 <- predict(model_hk_1, newdata = hk_seq, type = “response”)
hk_seq$prob_viable_5 <- predict(model_hk_5, newdata = hk_seq, type = “response”)
hk_seq$prob_viable_10 <- predict(model_hk_10, newdata = hk_seq, type = “response”)
hk_seq$prob_viable_20 <- predict(model_hk_20, newdata = hk_seq, type = “response”)
hk_seq$group <- ifelse(hk_seq$high_k == 0, “Below 35% K-Rate”, “35%+ K-Rate”)
# ── Bucket significance extraction ────────────────────────────────────────
extract_model_info <- function(model, threshold_label) {
coefs <- summary(model)$coefficients
data.frame(
threshold = threshold_label,
k_bucket = rownames(coefs),
coef = coefs[, “Estimate”],
p_value = coefs[, “Pr(>|z|)”]
) |>
filter(grepl(“k_bucket_full”, k_bucket)) |>
mutate(
k_bucket = gsub(“k_bucket_full”, “”, k_bucket),
sig = case_when(
p_value < 0.001 ~ “***”,
p_value < 0.01 ~ “**”,
p_value < 0.05 ~ “*”,
p_value < 0.10 ~ “.”,
TRUE ~ “”
)
)
}
results_table <- bind_rows(
extract_model_info(model_buck_1, “$1”),
extract_model_info(model_buck_5, “$5”),
extract_model_info(model_buck_10, “$10”),
extract_model_info(model_buck_20, “$20”)
)
sig_wide <- results_table |>
select(threshold, k_bucket, sig) |>
pivot_wider(names_from = threshold, values_from = sig, names_prefix = “sig_”) |>
rename(sig_1 =
sig_$1, sig_5 =sig_$5, sig_10 =sig_$10, sig_20 =sig_$20)# ── Binary significance extraction ────────────────────────────────────────
hk_sig <- data.frame(
threshold = c(“$1+”, “$5+”, “$10+”, “$20+”),
p_value = c(
summary(model_hk_1)$coefficients[“high_k”, “Pr(>|z|)”],
summary(model_hk_5)$coefficients[“high_k”, “Pr(>|z|)”],
summary(model_hk_10)$coefficients[“high_k”, “Pr(>|z|)”],
summary(model_hk_20)$coefficients[“high_k”, “Pr(>|z|)”]
)
) |>
mutate(sig = case_when(
p_value < 0.001 ~ “***”,
p_value < 0.01 ~ “**”,
p_value < 0.05 ~ “*”,
p_value < 0.10 ~ “.”,
TRUE ~ “”
))
# ── Odds ratio data ────────────────────────────────────────────────────────
or_rows <- lapply(list(
list(label = “$1+”, model = model_cont_1),
list(label = “$5+”, model = model_cont_5),
list(label = “$10+”, model = model_cont_10),
list(label = “$20+”, model = model_cont_20)
), function(x) {
coefs <- summary(x$model)$coefficients
beta <- coefs[“K_pct”, “Estimate”]
se <- coefs[“K_pct”, “Std. Error”]
p_value <- coefs[“K_pct”, “Pr(>|z|)”]
or <- exp(beta * 0.01)
or_lo <- exp((beta – 1.96 * se) * 0.01)
or_hi <- exp((beta + 1.96 * se) * 0.01)
data.frame(
threshold = x$label,
reduction = round((1 – or) * 100, 2),
ci_lo = round((1 – or_hi) * 100, 2),
ci_hi = round((1 – or_lo) * 100, 2),
p_value = p_value,
sig = case_when(
p_value < 0.001 ~ “***”,
p_value < 0.01 ~ “**”,
p_value < 0.05 ~ “*”,
p_value < 0.10 ~ “.”,
TRUE ~ “”
)
)
})
or_data <- bind_rows(or_rows)
# ── Populated buckets for display filtering ────────────────────────────────
populated_buckets <- lb_model |>
count(k_bucket_full) |>
filter(n >= 10) |>
pull(k_bucket_full) |>
as.character()
# ── Return models and raw prediction data ─────────────────────────────────
list(
models_cont = list(m1 = model_cont_1, m5 = model_cont_5,
m10 = model_cont_10, m20 = model_cont_20),
models_buck = list(m1 = model_buck_1, m5 = model_buck_5,
m10 = model_buck_10, m20 = model_buck_20),
models_hk = list(m1 = model_hk_1, m5 = model_hk_5,
m10 = model_hk_10, m20 = model_hk_20),
k_seq = k_seq, # continuous prediction data
bucket_seq = bucket_seq, # bucket prediction data
hk_seq = hk_seq, # binary prediction data
sig_wide = sig_wide, # bucket significance flags
hk_sig = hk_sig, # binary significance flags
or_data = or_data, # odds ratio data
populated_buckets = populated_buckets # buckets with >= 10 players
)
}
# ── Fit models for each leaderboard ───────────────────────────────────────────
models_30 <- fit_kpct_models(leaderboard_30)
models_60 <- fit_kpct_models(leaderboard_60)
models_120 <- fit_kpct_models(leaderboard_120)
# ── High debut K-rate players who became $20+ fantasy viable ──────────────────
leaderboard_60 |>
filter(ever_viable_20 == 1) |> # only players who hit $20+
arrange(desc(K_pct)) |> # sort by highest K-rate first
select(name_first, name_last, K_pct,
debut_year) |>
print(n = 60) # top 20 highest K-rate $20+ players
# ── 2023+ debutants with elevated debut K-rates ────────────────────────────────
leaderboard_60 |>
filter(as.integer(as.character(debut_year)) >= 2023) |>
filter(K_pct >= 0.35) |>
arrange(desc(K_pct)) |>
select(name_first, name_last, K_pct, debut_year
) |>
print(n = 100)
# Confidence intervals and 2020 sample
leaderboard_60 |>
filter(as.integer(as.character(debut_year)) >= 2020) |>
filter(K_pct >= 0.35) |>
nrow()
lapply(list(
“$1+” = models_60$models_hk$m1,
“$5+” = models_60$models_hk$m5,
“$10+” = models_60$models_hk$m10,
“$20+” = models_60$models_hk$m20
), function(m) {
intercept <- coef(m)[“(Intercept)”]
coef_hk <- coef(m)[“high_k”]
ci <- confint(m)[“high_k”, ]
data.frame(
prob_est = 1 / (1 + exp(-(intercept + coef_hk))),
prob_lo = 1 / (1 + exp(-(intercept + ci[1]))),
prob_hi = 1 / (1 + exp(-(intercept + ci[2])))
)
}) |>
bind_rows(.id = “threshold”)
# ── Expected viable players from 89 post-2020 high-K debutants ────────────────
data.frame(
threshold = c(“$1+”, “$5+”, “$10+”, “$20+”),
prob_est = c(0.12517224, 0.13303618, 0.10722715, 0.05943175),
prob_lo = c(0.09092494, 0.09527953, 0.07461939, 0.03592529),
prob_hi = c(0.16630431, 0.17844259, 0.14755497, 0.09146818)
) |>
mutate(
n = 89,
exp_est = round(n * prob_est, 1),
exp_lo = round(n * prob_lo, 1),
exp_hi = round(n * prob_hi, 1)
) |>
select(threshold, exp_lo, exp_est, exp_hi)
leaderboard_60 |>
filter(as.integer(as.character(debut_year)) >= 2020) |>
filter(K_pct >= 0.35) |>
summarise(
viable_1 = sum(ever_viable_1, na.rm = TRUE),
viable_5 = sum(ever_viable_5, na.rm = TRUE),
viable_10 = sum(ever_viable_10, na.rm = TRUE),
viable_20 = sum(ever_viable_20, na.rm = TRUE)
)
leaderboard_60 |>
group_by(debut_year) |>
summarise(
n_total = n(),
n_high_k = sum(K_pct >= 0.35),
pct_high_k = round(mean(K_pct >= 0.35) * 100, 1),
.groups = “drop”
) |>
arrange(as.integer(as.character(debut_year))) |>
print(n = 30)
So Owen Caissie is a dynasty drop?
In my 120 PA sample (Caissie is >200 now and has 41.8% k-rate), players in the 40-45% bucket were only 10% likely to ever register a $1 viable season. So ymmv but not getting my hopes up here.
Hey, it’s my questions! And, btw, whether to drop Caissie was more or less the real-world prompt for me asking Chad this question. Thanks Chad and thanks JVL for answering!
(I did drop Caissie, btw.)
If I were to pose some follow-ups, they would probably be about
a) doing something to try to control for “other factors that might influence promotion”: a top prospect might be pushed up levels quickly because the team “wants to challenge him,” they might be decimated by injury or he might have a fantastic glove and they don’t care if he can’t hit, age, etc.
b) when you make less contact, it makes sense that you will have more variability in your results (so that $5 being close to $1 result doesn’t surprise me, even if the current version is logically strange) — so it would be interesting to look at lifetime value rather than “did he have a viable season ever.” Domingo Santana had one good season but I am not sure that means he should ever have been rostered — or at least players should know that expected value may be hiding a very different distribution. And other such characterizations of K rate and variability: a 35 k% player might be more like “he’s either $10 or $0” whereas a 20 k% might be more like “he’s gonna give you $1 every year.”
c) controlling for other aspects of hitter profile (you could go wild with this, but an obvious one is pre-callup K rate: Is it better or worse for a player to be promoted after struggling with k rate in the minors and continue to struggle, or to newly struggle in the majors after having a <30 k rate in minors?)
I’d be curious how much using 120 PA would change things. Looking at the “eventual $20 season” list, there are at least a few guys that if you run it to 120 PA, they drop below 35%. For example, it was at almost exactly 60 PA that Goldy morphed into pretty much the stud he would be for the next 13 years. Pat Burrell and Derrek Lee are 2 other guys that are probably below 35% if you run it out to 120 PA.
It also seems like if a guy overcomes that initial 35% K binge to be fantasy relevant, they are fantasy relevant for quite a few years. I see a couple one-and-dones in the list, but for the most part they’re guys who were (or are) fantasy viable for a half decade or more.
Looking at the current list (& because I’m thinking of picking him up), Holliday didn’t quit get it under 35% by 120 PA, but it wasn’t long after that and that super high K% hasn’t really been part of his game since that initial start.