Introduction
People love a full moon. Supermoon invaded the lexicon (despite being only marginally larger than any other full moon) over the last few decades, along with many other moon superlatives. Blue moon had been around for a while, as well as harvest moon but lots of other moons have shown up in the media. (Can we please go back to just saying lunar eclipse and not blood moon?)
Among lunar related topics, one belief has long persisted - that certain events or trends are more likely during a full moon. Perhaps, the full moon changes behavior or there are more accidents, ER visits, crime, etc. during the full moon. This is called the Full Moon Myth and, as you might guess from the myth in the title, there is no actual data or evidence behind it at all.
There’s no “lunar lunacy” (Rotton and Kelly 1985), and there’s no increase in ER visits (VanMeter et al. 2024). This 2009 Scientific American article is a good reference for other non-existent full moon correlations.
Google ran a “Gemini with Google Cloud” commercial during the World Series that made 3 dubious claims about stats changing during the full moon. All are examples of small number statistics with large error bars, and analyzing more than one year of data (I used 1900-2025) shows any effect of the full moon to be spurious.
My initial version of this post used the wrong UTC for the moon phase, probably shifting it by a bit less than a day. Midnight UTC July 2 is the evening of July 1, and I’m joining on date. I have updated it to use 21:00 UTC which is 5PM EDT. That splits the difference between night games and day games, though there were fewer night games in the early 20th century (and no daylight saving time) but I’m not going to try to account for that.
I have thought more about what it means to be a “full moon” and added a discussion of what is the appropriate definition.
Google’s commercial : “Can a full moon bring out a game’s wild side?”
So, with the full moon myth in mind, you can imagine my reaction when I saw this Google Gemini / Cloud ad while watching Game 1 of the World Series:
The ad opens with the question: “Can a full moon bring out a game’s wild side?” Well, “Gemini on Google Cloud” looked into it!1 The commercial makes the following claims for the 2025 MLB season:
- Multi-run late-inning comebacks2 rose 34% under a full moon.
- Seven-plus run blowout games jumped 25%.
- The rate for “homers that howled over 450 feet more than doubled.”
I posted on Blue Sky almost immediately upon seeing the commercial:
Did a Google Cloud ad just claim that the full moon changes baseball stats? That's … nuts and now I'm going to have to join lunar phase data with baseball data to show this is nonsense. I know one study once showed accident/moon correlation but only because the moon coincided with weekends.
— Marcos Huerta (@marcoshuerta.com) 2026-10-24T00:51:14.775Z
While I posted some preliminary results in the Bluesky thread itself, I will summarize and expand upon my findings in this post.3
Analysis
Baseball Data
Retrosheet4 has two files I can use for my analysis, The file gameinfo.csv has final scores and stats for each game, and teamstats.csv has 2 lines (one for each team, sometimes more) for each game that includes runs per inning (which I’ll need to look at the late inning comeback claim.)
The “450+” home run claim requires home run data, so I turned to Statcast data from Baseball Savant. Unlike Retrosheet, which goes back to the 19th century, the Statcast data only goes back to 20085.
Most of the code blocks below are calling custom functions I wrote, see the code section at the end for the all of those function definitions.
Moon Data
For the lunar phase data, I created a daily ephemeris at JPL Horizons Systems for the moon. These aren’t CSVs, but more like fixed width data files with non-standard headers and footers so I manually removed these extra lines with BBEdit and read in using pandas read_table.
What is a full moon?
In the initial version of this post, I just used > 99% illumination as my definition of full moon. Any date when phase > .99 counted as a full moon game. But, it’s a sinusoidal curve - it’s not uncommon for the moon to be > 99% for 2 days in row.
A strict definition of full moon, where it’s 1 day per peak (using the local maximum) would mean only 83 games played under full moons for 2025 on the following 7 dates:
- April 12
- May 12
- June 10
- July 10
- August 8
- September 7
- October 6
Using the > 99% illuminated criteria, that adds 6 more ‘full moons’ listed below, almost doubling the 2025 “full moon game” count to 161.
- April 13
- May 11
- June 11
- August 9
- September 6
- October 7
I think the “strict” definition is most likely the data that Google used, so I have redone my calculations since the initial post to only use “local max” full moons, now joined on 21:00 UTC.
Claim: Seven-plus run blowout games jumped 25%
This is the second claim in the ad, but it’s the easiest to analyze so I’ll walk through it first. I actually found a slightly different than 25% increase for 2025.
2025 Analysis
As mentioned above, there were 83 games played under a full moon in 2025, and 2395 games played without a full moon. The total number of 7+ run blowouts during a full moon is 15.
Show the code
from baseball import (
make_one_df_range,
make_fake_phase,
get_merged_data,
make_phase_bin_data,
clean_name,
make_fake_vs_real_full_moons,
find_col,
make_moon,
make_table,
)
import pandas as pd
import polars as pl
import plotly.express as px
merged = get_merged_data()
make_one_df_range(
merged.filter(pl.col('season').eq(2025)),
stat='large_victory',
range_size=1,
max_full_moon=True,
).pipe(make_table, 'large_victory')| Large Victory Full Moon Statistics | ||||
| Moon Status | Large Victory Rate | Large Victory Rate Error | Game Count | Large Victory Game Count |
|---|---|---|---|---|
| False | 14.15% | 1.40% | 2395 | 339 |
| True | 18.07% | 8.28% | 83 | 15 |
h_{\bullet} = \frac{339}{2395} = 0.14
h_{\circ} = \frac{15}{83} = 0.18
Due to the small number of games played under a full moon in 2025, and the rarity of blowouts, the 27% increase has an error bar of … ± 59%. It’s also cherry picked because there’s a negative effect at 5+ and 9+ runs.
Show the code
blowouts_2025 = pd.concat([
make_phase_bin_data(
merged.filter(pl.col('season').eq(2025)),
phase_range=[99],
range_size=2,
stat='large_victory',
threshold=threshold,
)[['ratio', 'ratio_error']].assign(pct_increase=pd.col('ratio') - 1, threshold=threshold)
for threshold in range(5, 11)
])
fig = px.bar(
pl.from_pandas(blowouts_2025).with_columns(pl.col('threshold').cast(pl.String) + '+ Runs'),
y='threshold',
x='pct_increase',
error_x='ratio_error',
title='Percent Increase of Blowouts during Full Moon 2025',
labels={x: clean_name(x) for x in ['threshold', 'pct_increase']},
)
fig.update_xaxes(tickformat='.1%')So, regardless of how one defines ‘blowouts’, the 2025 data has no signal outside the error bars. I can’t even call this p-hacking because none of these are statistically significant. Google cherry-picked data, and ignored the small sample size and error bars.
We need more data to shrink those error bars, so next I’ll look at every season since 1900!
Every game since 1900
Show the code
blowouts_since_1900 = pd.concat([
make_phase_bin_data(
merged,
phase_range=[99],
range_size=1,
stat='large_victory',
threshold=threshold,
)[['ratio', 'ratio_error']].assign(pct_increase=pd.col('ratio') - 1, threshold=threshold)
for threshold in range(5, 11)
])
fig = px.bar(
pl.from_pandas(blowouts_since_1900).with_columns(
pl.col('threshold').cast(pl.String) + '+ Runs'
),
y='threshold',
x='pct_increase',
error_x='ratio_error',
title='Percent Increase of Blowouts during Full Moon 1900-2025',
labels={x: clean_name(x) for x in ['threshold', 'pct_increase']},
)
fig.update_xaxes(tickformat='.1%')I doubt Google would make an ad about a 2.1% increase during a full moon.
Perhaps you don’t love the error bars from the Wald method. I simulated 1000 fake moons with random periods. With these fake moons, I can compute the same “% increase” on all the fake full moons, and look at the distribution.
Show the code
fake_moons_all_seasons = make_fake_vs_real_full_moons(merged, n=1000)
px.histogram(
fake_moons_all_seasons.with_columns(
(pl.col('full_moon_ratio') - 1).alias('Full Moon % Increase of 7+ Run wins')
),
x='Full Moon % Increase of 7+ Run wins',
color='fake moon',
title='1900-2025, 7+ run Margin of Victory Fake Moon phase Histogram',
)Plenty of the fake full moon cycles reach the same or higher “full moon % increase” as the real moon. There’s not much signal here.
I’m declaring Claim 2 debunked.
Claim: Multi-run late-inning comebacks rose 34% under a full moon.
This was a bit harder to compute. Thankfully the teamstats.csv from Retrosheet has 2 rows per team and runs by innings. With a little finesse, I can make a “comeback lookup” dataframe to merge into gameinfo.csv data. To make things simpler, I only look at the score/margin after the end of a full inning.
I’m going to assume “multi-run” comeback just means 2 or more runs, but “late inning”- how did they define it? Once again I’ll just try several run/inning combinations for 2025, but despite trying 2+ and 3+ leads after multiple innings, I haven’t been able to reproduce the exact 34% increase. I can show you giant error bars, once again.
Show the code
import itertools
def make_comeback_expr(inning=6, lead=2):
return (
pl.col(f'comeback_after_{inning}').eq(-1)
& (pl.col(f'score_after_{inning}_h') - pl.col(f'score_after_{inning}_v'))
.abs()
.ge(lead)
).alias(f'comeback_of_{lead}_after_{inning}')
teamstats = pl.read_csv('teamstats.csv', infer_schema_length=100000, ignore_errors=True)
gid_comeback_lookup = (
teamstats
.filter(pl.col('stattype').eq('value'))
.with_columns(score_after_5=pl.sum_horizontal([f'inn{n}' for n in range(1, 6)]))
.with_columns(score_after_6=pl.sum_horizontal([f'inn{n}' for n in range(1, 7)]))
.with_columns(score_after_7=pl.sum_horizontal([f'inn{n}' for n in range(1, 8)]))
.with_columns(score_after_8=pl.sum_horizontal([f'inn{n}' for n in range(1, 9)]))
.select([
'gid',
'vishome',
'win',
'score_after_5',
'score_after_6',
'score_after_7',
'score_after_8',
])
.pivot(
on='vishome',
values=['score_after_5', 'score_after_6', 'score_after_7', 'score_after_8', 'win'],
index='gid',
)
.with_columns(*[
(
(pl.col(f'score_after_{n}_h') - pl.col(f'score_after_{n}_v')).sign()
* (pl.col('win_h') - pl.col('win_v')).sign()
).alias(f'comeback_after_{n}')
for n in range(5, 9)
])
.with_columns(*[
make_comeback_expr(i, l) for i, l in itertools.product([5, 6, 7, 8], [2, 3])
])
)2025 Comeback Analysis
Show the code
merged_comebacks = (
merged.lazy().join(gid_comeback_lookup.lazy(), on='gid', how='left').collect()
)
make_one_df_range(
merged_comebacks.filter(pl.col('season').eq(2025)),
stat='comeback_of_2_after_6',
phase=99,
range_size=1,
max_full_moon=True,
).pipe(make_table, 'comeback_of_2_after_6')| Comeback Of 2 After 6 Full Moon Statistics | ||||
| Moon Status | Comeback Of 2 After 6 Rate | Comeback Of 2 After 6 Rate Error | Game Count | Comeback Of 2 After 6 Game Count |
|---|---|---|---|---|
| False | 4.09% | 0.79% | 2395 | 98 |
| True | 8.43% | 5.98% | 83 | 7 |
Of the 83 games during a full moon in 2025, there were 7 comebacks of 2+ runs after 6 full innings for a rate of 8.43%. (98 in 2395 non-full-moon games.)
Show the code
comebacks_2025 = pd.concat([
make_phase_bin_data(
merged_comebacks.filter(pl.col('season').eq(2025)),
phase_range=[99],
stat=stat,
)[['ratio', 'ratio_error', 'count_true']].assign(
stat_name=stat, pct_increase=pd.col('ratio') - 1
)
for stat in find_col(merged_comebacks, 'comeback_of')
])
fig = px.bar(
pl.from_pandas(comebacks_2025).with_columns(
pl
.col('stat_name')
.str.replace_all('_', ' ', literal=True)
.str.to_titlecase()
.str.replace_all(r'Of (\d)', r'Of $1+')
),
y='stat_name',
x='pct_increase',
error_x='ratio_error',
hover_data=['count_true'],
title='Percent Increase of Late Multi-Run comebacks during Full Moon 2025',
)
fig.update_xaxes(tickformat='.1%')Comebacks of 2+ runs after 5 full innings is 24% ± 98%… that’s the closest I could get to the 34% increase. The strongest effect looks like 3 or more runs after 8 full innings, which is 2(!) comebacks out of 83 games and a whopping 861% increase! But with an even bigger error bar. These are some big percent increases, but at this sample size still very much small number statistics, with non-meaningful results and large error bars.
1900-2025 Comeback Analysis
Since 1900, sometimes the trend seems to be fewer comebacks during the full moon, sometimes more, but again - never outside the large error bars. The strongest “signal” is probably 2+ runs after 8 which is up 11%. I ran some fake moon simulations for this scenario.
Show the code
sim_data = make_fake_vs_real_full_moons(merged_comebacks, n=1000, stat='comeback_of_2_after_8')
px.histogram(
sim_data.with_columns(
(pl.col('full_moon_ratio') - 1).alias(
'Full moon % Increase of Late Inning, Multi Run comebacks'
)
),
x='Full moon % Increase of Late Inning, Multi Run comebacks',
color='fake moon',
title='1900-2025, Real Moon vs Simulation Histogram of Late Inning, Multi-run Comebacks',
subtitle='Comebacks of 2+ run after 8',
)I mean… it’s not completely nothing? It’s about 1 sigma. But we’re clearly hunting desperately for signal by looking at a bunch of different definitions - pure p-hacking.
I find it notable that the broadest criteria with the most sample is 2+ rallies after 5 full innings and there’s not much there: a 0.8% decrease.
I’m declaring Claim 1 debunked.
Claim: Rate for homers that howled over 450 feet more than doubled.
My initial take here, using the broader definition of a full moon (> 99% illuminated), and slightly wrong lunar phase data showed no strong effect (+20%). Using the better lunar data with only 1 full moon per month, I found some very large (but still in the noise) increases.
Since the first version posted, I also restricted the analysis to regular season games.
I was not 100% sure I know what this one means. I’m pretty sure it means \frac{h_{450}}{h}, i.e. the rate of the 450+ homers with all home runs as the denominator. It could possibly mean “rate of games that have 450+ home runs”? The data is from Statcast and that link should show you the results I used, with a save button to save the CSV6.
There were 95 homers with Statcast estimated distances of 450 feet or higher in 2025. There were 5646 total homers. During a full moon, \frac{7}{179} homers were 450+, or 3.9%. During all other times, it was \frac{88}{5467}, or 1.61%.
That’s a 142.9% \pm 183% higher rate. A big increase! Yet, an increase with a massive error bar on a very small absolute count - we’re talking 7 home runs.
I made a graph with error bars with rates for home runs of difference distances, there’s negative or no signal at 400+ feet, the strongest signal might be at the 410 feet threshold which is almost 2 sigma.
Show the code
homer_data = pl.read_csv('savant_data.csv').with_columns(
date=pl.col('game_date').str.to_date('%Y-%m-%d')
)
moon_data = make_moon()
merged = (
homer_data
.join(
moon_data,
on='date',
)
.with_columns(*[
pl.col('hit_distance_sc').ge(d).alias(f'homer_over_{d}') for d in range(380, 470, 10)
])
.with_row_index('gid')
)Show the code
homers_by_distance = pd.concat([
make_phase_bin_data(merged, phase_range=[99], stat=stat)[['ratio', 'ratio_error']].assign(
stat_name=stat, pct_increase=pd.col('ratio') - 1
)
for stat in [
'homer_over_380',
'homer_over_390',
'homer_over_400',
'homer_over_410',
'homer_over_420',
'homer_over_430',
'homer_over_440',
'homer_over_450',
'homer_over_460',
# 'homer_over_470',
]
])
fig = px.bar(
pl.from_pandas(homers_by_distance).with_columns(
pl
.col('stat_name')
.str.replace_all('_', ' ', literal=True)
.str.replace_all('homer', 'Homers')
.str.to_titlecase()
.str.replace_all(r'Of (\d)', r'Of $1+')
),
y='stat_name',
x='pct_increase',
error_x='ratio_error',
labels={
'pct_increase': 'Percent Increase during Full Moon',
'stat_name': 'Home run distance',
},
title='Percent Increase of Homers during the Full Moon, 2025',
)
fig.update_xaxes(tickformat='.1%')These numbers are based on tiny totals. There are 0 home runs during a full moon over 470 feet, and 2 over 460 feet during the full moon. These are the definition of small number stats.
2024 Data
Let’s look at 2024. I struggled to get Baseball Savant to generate the right CSV for 2024 - the data looked right on my screen, but the download button kept giving me other, unrelated data. I had to save the HTML, and use pandas read_html to to the data I could see in my browser into a CSV.
Show the code
homer_data = pl.read_csv('homers_2024_from_web.csv').with_columns(
date=pl.col('game_date').str.to_date('%Y-%m-%d'))
merged = (homer_data.join(
moon_data,
on='date',
).with_columns(*[
pl.col('hit_distance_sc').ge(d).alias(f'homer_over_{d}')
for d in range(380, 470, 10)
]).with_row_index('gid'))
homers_by_distance = pd.concat([
make_phase_bin_data(merged, phase_range=[99],
stat=stat)[['ratio', 'ratio_error'
]].assign(stat_name=stat,
pct_increase=pd.col('ratio') - 1)
for stat in [
'homer_over_380',
'homer_over_390',
'homer_over_400',
'homer_over_410',
'homer_over_420',
'homer_over_430',
'homer_over_440',
'homer_over_450',
'homer_over_460',
# 'homer_over_470',
]
])
fig = px.bar(
pl.from_pandas(homers_by_distance).with_columns(
pl.col('stat_name').str.replace_all(
'_', ' ', literal=True).str.replace_all(
'homer', 'Homers').str.to_titlecase().str.replace_all(
r'Of (\d)', r'Of $1+')),
y='stat_name',
x='pct_increase',
error_x='ratio_error',
labels={
'pct_increase': 'Percent Increase during Full Moon',
'stat_name': 'Home run distance',
},
title='Percent Increase of Homers during the Full Moon, 2024',
)
fig.update_xaxes(tickformat='.1%')I guess 430+ homer rates look somewhat strong again? Yet, 440+ is essentially zero. This is definitely p-hacking, looking at a bunch of metrics trying to find anything that might look significant. I’m declaring Claim 3: Debunked.7
Follow up, Home Runs, 2015-2026
Statcast data for computed distance is available since 2015. I had various issues getting the CSV downloads at Baseball Savant to work, something about the url was misconstructed. I copied out code from baseballr package to make my own CSV urls, and that worked.
Show the code
homersall_parquet = pl.read_parquet('statcast_homers_2015_to_2026.parquet')
merged_all = homersall_parquet.join(moon_data, on='date').with_columns(*[
pl.col('hit_distance_sc').ge(d).alias(f"homer_over_{d}")
for d in range(380, 500, 10)
]).with_row_index('gid')
homers_by_distance = pd.concat([
make_phase_bin_data(merged_all.filter(pl.col('date').dt.year().ge(2015)),
phase_range=[99],
range_size=2,
max_full_moon=True,
stat=stat)[['ratio', 'ratio_error'
]].assign(stat_name=stat,
pct_increase=pd.col('ratio') - 1)
for stat in [
'homer_over_380',
'homer_over_390',
'homer_over_400',
'homer_over_410',
'homer_over_420',
'homer_over_430',
'homer_over_440',
'homer_over_450',
'homer_over_460',
'homer_over_470',
]
])
fig = px.bar(
pl.from_pandas(homers_by_distance).with_columns(
pl.col('stat_name').str.replace_all(
'_', ' ', literal=True).str.replace_all(
'homer', 'Homers').str.to_titlecase().str.replace_all(
r'Of (\d)', r'Of $1+')),
y='stat_name',
x='pct_increase',
error_x='ratio_error',
title='Percent Increase of Homers during the Full Moon 2015-2026',
)
fig.update_xaxes(tickformat='.1%')This still feels like p-hacking. 450 feet home runs are still within the error bar. And even with 11 years of data, these are very small percentages as there are only 5-6 full moons a season, so these are still rates between 2% and 5%, with 52 home runs during full moons.
Here are the total numbers for 450+ home runs.
Show the code
make_one_df_range(
merged_all,
stat='homer_over_450',
phase=99,
range_size=1,
max_full_moon=True,
).pipe(make_table, 'homer_over_450')| Homer Over 450 Full Moon Statistics | ||||
| Moon Status | Homer Over 450 Rate | Homer Over 450 Rate Error | Game Count | Homer Over 450 Game Count |
|---|---|---|---|---|
| False | 2.12% | 0.12% | 58709 | 1232 |
| True | 2.51% | 0.67% | 2096 | 52 |
Maybe there is there something going on at homers > 410 feet? It does seem outside the error bar! I will run some simulations with fake moons.
Show the code
fake_moons_all_seasons = make_fake_vs_real_full_moons(merged_all,
n=1500,
stat='homer_over_410',
max_full_moon=True)
px.histogram(
fake_moons_all_seasons.with_columns((pl.col('full_moon_ratio') - 1)),
labels={
'full_moon_ratio': 'Full Moon % increase of 410FT Home Runs',
'fake_moon': 'Fake Moon'
},
x='full_moon_ratio',
color='fake moon',
title=
'2015-2026, Real Moon vs Simulation Histogram of rate of 410FT Home Runs',
)The standard deviation here is 0.046, so the 7.6% increase is under 2 sigma when computed with the simulations. This feels like more p-hacking.
If the full moon was exherting influence, perhaps we’d see a pattern depending on how illimunated the moon is?
Show the code
phase_vs_homers = make_phase_bin_data(
merged_all,
phase_range=range(0, 100, 1),
stat='homer_over_410',
max_full_moon=False,
range_size=1,
# fake_moon_period=27.2889,
)
fig = px.scatter(phase_vs_homers.assign(phase_bin=pd.col('phase_bin') / 100,
ratio=pd.col('ratio') - 1),
x='phase_bin',
y='ratio',
error_y='ratio_error',
title='Moon Illimunation and Home Run Rate',
labels={
'ratio': "% Increase of Home Runs over 410 Ft",
'phase_bin': 'Moon Illumination'
})
_ = fig.update_xaxes(tickformat='0%')
fig.update_yaxes(tickformat='.1%')That’s just noise. At 56% and 89% illumination, there’s a big drop in the rate of homers over 410 feet! Stop the presses!
I think any analyst looking at this would see it’s just a noisy signal that occasionally looks real. There’s no coherent trend - which is of course what we’d expect. I reaffirm that Claim 3 is Debunked.
Conclusion - Did Gemini mess this up?
I had assumed that some Google marketing types asked some MLB data team members8 for some lunar factoids and some poor analyst when probably knew better sent them whatever they could find: error bars be damned.
But maybe they really did use Gemini. The ad included 3 stats that are all a little true – if you ignore the error bars, and the home run stat in particular has some enormous error bars. That feels like what might happen if you ask an LLM to do data analysis without babysitting it carefully. Agentic p-hacking? Yet, not really p-hacking since it still couldn’t find a statistically significant result.
If you’re looking for something the moon actually influences, perhaps read about the tides!
Source Code and details
Code for various utility functions used in the above code cells is included below. I mostly used Polars, but in order to take advantage of error propagation from the uncertainties package, I switched to pandas a few times.
Data Processing Functions
import math
import os
import random
import re
import pandas as pd
import polars as pl
from tqdm.autonotebook import tqdm
from uncertainties import ufloat
from blob_cache import blob_cache
def rate_error(col, col_count):
return 1.96 * (pl.col(col) * (1 - pl.col(col)) / pl.col(col_count)).sqrt()
def pipe_rate_error(_df: pl.LazyFrame | pl.DataFrame, col, col_count=None, out_name=None):
if not out_name:
out_name = f'{col}_error'
if not col_count:
col_count = f'{col}_count'
if col_count not in _df.columns:
_df = _df.with_columns(pl.col(col).count().alias(col_count))
return _df.with_columns(rate_error(col, col_count).alias(out_name))
def make_fake_phase(_df: pl.LazyFrame, period: float = 26.7) -> pl.LazyFrame:
new_data = (
(
pl
.DataFrame(pl.date_range(pl.date(1900, 1, 1), pl.date(2026, 1, 1), eager=True))
.lazy()
.with_columns(days_since=(pl.col('date') - pl.col('date').min()).dt.total_days())
.unique(subset=['date'])
.select(['date', 'days_since'])
.with_columns(
phase=100 * (0.5 + 0.5 * (math.pi * pl.col('days_since') / period).cos())
)
)
.sort('days_since')
.with_columns(full_moon_flag=pl.col('phase').peak_max())
)
return _df.drop(['phase', 'full_moon_flag']).join(new_data, on='date', how='left')
def make_one_df_range(
_merged,
threshold=7,
phase=99,
range_size=1,
fake_moon_period=None,
group_by=['Moon Status'],
stat='large_victory',
max_full_moon=True,
):
merged = _merged.lazy()
if stat == 'large_victory': # really should move stat creation out of this function
merged = merged.with_columns(large_victory=pl.col('margin_of_victory').ge(threshold))
if fake_moon_period:
merged = merged.pipe(make_fake_phase, fake_moon_period)
return (
merged
.with_columns(
pl
.when(max_full_moon)
.then(pl.col('full_moon_flag'))
.otherwise(pl.col('phase').is_between(phase, phase + range_size))
.alias('Moon Status'),
)
.group_by(group_by, maintain_order=True)
.agg(
pl.col(stat).mean().alias(f'{stat}_rate'),
pl.col('phase').mean(),
pl.col('gid').count().alias('count'),
pl.col(stat).sum().alias(f'{stat}_sum'),
# pl.col('daynight').eq('day').mean().alias('day_game_flag'),
)
.with_columns(blowout_threshold=threshold)
.collect()
.pipe(pipe_rate_error, f'{stat}_rate', 'count')
.with_columns(phase_bin=phase, fake_moon_period=fake_moon_period)
)
def clean_name(x):
return x.replace('_', ' ').title()
def find_col(obj, pattern, regex=False):
r"""Simple match column/key names in a dataframe/dictionary, optional regex. Without regex
does as simple "s in col_name"
usage:
find_col(_df,'appraisal')
or
find_col(_df,'\w+_\w+',regex=True)
"""
pattern = str(pattern)
if not hasattr(obj, 'columns'):
return []
if not regex:
return sorted([col for col in obj.columns if pattern in str(col)])
else:
return sorted([col for col in obj.columns if re.match(pattern, str(col))])
def make_phase_bin_data(
merged,
threshold=7,
fake_moon_period=None,
phase_range=[99],
range_size=1,
stat='large_victory',
max_full_moon=True,
):
phase_vs_all = (
(
pl.concat([
make_one_df_range(
merged,
threshold=threshold,
phase=phase,
range_size=range_size,
fake_moon_period=fake_moon_period,
stat=stat,
max_full_moon=max_full_moon,
)
for phase in phase_range
]).pivot(on='Moon Status', index='phase_bin', values=[f'{stat}_rate', 'count'])
)
.pipe(pipe_rate_error, f'{stat}_rate_true', 'count_true')
.pipe(pipe_rate_error, f'{stat}_rate_false', 'count_false')
)
phase_data_pandas = phase_vs_all.to_pandas()
phase_data_pandas[f'{stat}_rate_in_phase_range'] = phase_data_pandas[
[f'{stat}_rate_true', f'{stat}_rate_true_error']
].apply(lambda x: ufloat(x.iloc[0], x.iloc[1]), axis=1)
phase_data_pandas[f'{stat}_rate_out_of_phase_range'] = phase_data_pandas[
[f'{stat}_rate_false', f'{stat}_rate_false_error']
].apply(lambda x: ufloat(x.iloc[0], x.iloc[1]), axis=1)
phase_data_pandas = phase_data_pandas.assign(
ratio_ufloat=pd.col(f'{stat}_rate_in_phase_range')
/ pd.col(f'{stat}_rate_out_of_phase_range')
).assign(
ratio=pd.col('ratio_ufloat').apply(lambda x: x.n),
ratio_error=pd.col('ratio_ufloat').apply(lambda x: x.s),
fake_moon_period=fake_moon_period,
)
return phase_data_pandas
def get_all_baseballdata(start_year=1900):
return (
pl
.scan_csv(
'/Volumes/BMD/Large Data Sets/baseball_data/gameinfo.csv',
infer_schema_length=100000,
)
.with_columns(pl.col('date').cast(pl.String).str.to_date('%Y%m%d'))
.filter(pl.col('date').gt(pl.date(start_year, 1, 1)))
.with_columns(margin_of_victory=(pl.col('vruns') - pl.col('hruns')).abs())
).collect()
def make_moon(file='horizons_results.txt'):
pandas_df = pd.read_table(file, sep=r'\s{4,}', parse_dates=True, engine='python')
pandas_df.columns = ['date', 'phase']
pandas_df['date'] = pd.to_datetime(pandas_df['date'])
return (
pl
.from_pandas(pandas_df)
.with_columns(pl.col('date').dt.date())
.with_columns(full_moon_flag=pl.col('phase').peak_max())
)
def get_merged_data():
all_data = get_all_baseballdata()
moon_data = make_moon()
return all_data.join(moon_data, how='left', on=['date'])
@blob_cache(key='fake-moon-data2', keys_to_hash=['merged'])
def make_fake_vs_real_full_moons(
merged,
n=100,
threshold=7,
group=['Moon Status'],
stat='large_victory',
max_full_moon=True,
):
out = []
for _ in tqdm(range(n)):
random_period = random.randint(250000, 350000) / 10000
out.append(
make_one_df_range(
merged,
fake_moon_period=random_period,
threshold=threshold,
group_by=group,
stat=stat,
max_full_moon=max_full_moon,
)
)
out.append(
make_one_df_range(
merged,
group_by=group,
stat=stat,
fake_moon_period=None,
threshold=7,
)
)
return (
pl
.concat(out)
.pivot(
on='Moon Status',
index=['phase_bin', 'fake_moon_period']
+ (['season'] if 'season' in group else []),
values=[f'{stat}_rate'],
aggregate_function='first',
)
.with_columns(full_moon_ratio=pl.col('true') / 'false')
.with_columns(pl.col('fake_moon_period').is_not_null().alias('fake moon'))
)
def rename_for_gt(label):
if label == 'Moon Status':
return label
label_kind = label.split('_')[-1]
stat_name = '_'.join(label.split('_')[:-1])
match label_kind:
case 'count':
return 'Game Count'
case 'rate':
return clean_name(label)
case 'sum':
return f'{clean_name(stat_name)} Game Count'
case 'error':
return clean_name(label)
def make_table(df, stat_name):
return (
df
.drop(['fake_moon_period', 'blowout_threshold', 'phase', 'phase_bin'], strict=False)
.sort('Moon Status')
.style.fmt_percent(columns=[f'{stat_name}_rate', f'{stat_name}_rate_error'])
.cols_label_with(fn=rename_for_gt)
.cols_move(f'{stat_name}_rate_error', after=f'{stat_name}_rate')
.tab_header(title=f'{clean_name(stat_name)} Full Moon Statistics')
)References
Rotton, James, and I. W. Kelly. 1985. “Much Ado about the Full Moon: A Meta-Analysis of Lunar-Lunacy Research.” Psychological Bulletin 97 (2): 286–306. https://doi.org/10.1037/0033-2909.97.2.286.
VanMeter, David J., Timothy S. Strebel, Stephen M. Mastorides, and Andrew A. Borkowski. 2024. Moonlit Misconceptions: A South Florida Veterans’ Hospital Perspective on Full Moon Emergency Room Visits. October. https://doi.org/10.1101/2024.10.22.24315836.
Footnotes
This did not fill me with confidence.↩︎
Not defined!↩︎
I was incredibly both nerd-sniped and activated by someone being wrong on the internet at the same time.↩︎
The information used here was obtained free of charge from and is copyrighted by Retrosheet. Interested parties may contact Retrosheet at “www.retrosheet.org”.↩︎
Spoiler: I won’t need more than 2025 data↩︎
The download button was not working right for me.↩︎
If I can ever get the CSV downloads to work properly, I may come back and compute these stats for all available seasons is aggregate.↩︎
who definitely know what they’re doing, Baseball Savant is amazing↩︎