Using Python to find occurrences of Friday the 13th

Fri 13 September 2013 by Matthew Scott

Happy Friday the 13th!

For some, Friday the 13th is thought of to be rare and superstitious.

Approximately 1 out of 30 people occasionally have their birthday on a Friday the 13th. (And who doesn’t like having a birthday on a Friday?)

Superstition and late Friday nights aside, let’s find out a little more about Friday the 13th, using Python and the venerable IPython notebook to explore answer some questions.

Constants

To start out, define some constants that describe the space we’re exploring, starting with a limit for how far into the future we’ll peer.

Use datetime.date.today() to get today’s date, and the datetime.date() constructor to get a new date that represents the maximum date we are interested in.

In [1]:
import datetime

YEARS = 20
START = datetime.date(2013, 1, 1)

def years_from(date, years):
    """Return the last day of the year that is `years` ahead of `date`."""
    return datetime.date(date.year + years, 12, 31)

MAX_DATE = years_from(START, YEARS)

MAX_DATE
Out[1]:
datetime.date(2033, 12, 31)

Define a useful alias to make our code clearer to read:

In [2]:
FRIDAY = 5

When does Friday the 13th occur?

Define a generator that will give us intersections between a weekday and a day of the month:

In [3]:
def intersections(weekday, day, start_date=None):
    """Generate dates where the given weekday and day of month intersect.
    
    If the optional start_date is not given, uses the value of the `START` constant.
    
    """
    if start_date is None:
        date = START
    else:
        date = start_date
    while True:
        if date.day == day and date.isoweekday() == weekday:
            yield date
        date += datetime.timedelta(days=1)

Test it by finding the next few Friday the 13th occurrences using itertools.islice() to limit the results to 3 items:

In [4]:
from itertools import islice

friday_13_occurrences = intersections(FRIDAY, 13)
first_3 = islice(friday_13_occurrences, 3)
first_3
Out[4]:
<itertools.islice at 0x10e3f2b50>

What islice() returned here was not the three dates we’re looking for. It’s returning an object that is ready to return those dates once we begin iterating over it.

Pass it to list(), which causes the islice object to begin iterating over friday_13_intersections, stopping after the third.

In [5]:
list(first_3)
Out[5]:
[datetime.date(2013, 9, 13),
 datetime.date(2013, 12, 13),
 datetime.date(2014, 6, 13)]

If we try to re-use the islice object, it’s exhausted:

In [6]:
list(first_3)
Out[6]:
[]

If we recreate the islice iterable, we find different results because friday_13_occurrences has already yielded 3 items, and will now yield items 4 through 6:

In [7]:
list(islice(friday_13_occurrences, 3))
Out[7]:
[datetime.date(2015, 2, 13),
 datetime.date(2015, 3, 13),
 datetime.date(2015, 11, 13)]

We’re going to make repeated use of our specific call to intersections() , so make friday_13_occurrences a callable instead so it’s easier to repeat:

In [8]:
def friday_13_occurrences():
    return intersections(FRIDAY, 13)

list(islice(friday_13_occurrences(), 3))
Out[8]:
[datetime.date(2013, 9, 13),
 datetime.date(2013, 12, 13),
 datetime.date(2014, 6, 13)]

We now get a fresh generator each time we call friday_13_occurrences():

In [9]:
list(islice(friday_13_occurrences(), 3))
Out[9]:
[datetime.date(2013, 9, 13),
 datetime.date(2013, 12, 13),
 datetime.date(2014, 6, 13)]

We’ll use this pattern from now on: when we need a generator for something, define a function that, when called, returns a new instance of that kind of generator.

When does Friday the 13th occur over the next several years?

Use itertools.takewhile() to iterate over friday_13_occurrences() until a condition is met.

The first argument to takewhile() is a predicate, which is a callable that is given each item generated and returns True if iteration should continue. Define date_is_before_max() as our predicate.

Wrap that up in friday_13_before_max(), which uses our MAX_DATE constant as a default, but can accept other dates as well. (You’ll see how we give an alternate max_date further below.)

In [10]:
from itertools import takewhile

def friday_13_before_max(max_date=MAX_DATE):
    def date_is_before_max(date):
        return date <= max_date
    return takewhile(date_is_before_max, friday_13_occurrences())

list(friday_13_before_max())
Out[10]:
[datetime.date(2013, 9, 13),
 datetime.date(2013, 12, 13),
 datetime.date(2014, 6, 13),
 datetime.date(2015, 2, 13),
 datetime.date(2015, 3, 13),
 datetime.date(2015, 11, 13),
 datetime.date(2016, 5, 13),
 datetime.date(2017, 1, 13),
 datetime.date(2017, 10, 13),
 datetime.date(2018, 4, 13),
 datetime.date(2018, 7, 13),
 datetime.date(2019, 9, 13),
 datetime.date(2019, 12, 13),
 datetime.date(2020, 3, 13),
 datetime.date(2020, 11, 13),
 datetime.date(2021, 8, 13),
 datetime.date(2022, 5, 13),
 datetime.date(2023, 1, 13),
 datetime.date(2023, 10, 13),
 datetime.date(2024, 9, 13),
 datetime.date(2024, 12, 13),
 datetime.date(2025, 6, 13),
 datetime.date(2026, 2, 13),
 datetime.date(2026, 3, 13),
 datetime.date(2026, 11, 13),
 datetime.date(2027, 8, 13),
 datetime.date(2028, 10, 13),
 datetime.date(2029, 4, 13),
 datetime.date(2029, 7, 13),
 datetime.date(2030, 9, 13),
 datetime.date(2030, 12, 13),
 datetime.date(2031, 6, 13),
 datetime.date(2032, 2, 13),
 datetime.date(2032, 8, 13),
 datetime.date(2033, 5, 13)]

This is a little too mundane though, and the output is a bit cumbersome. Boring presentation of data is still boring no matter how accurate it is!

Let’s do some grouping and see if we can find something more interesting.

Which months each year have Friday the 13th?

Use itertools.groupby() to group occurrences of Friday the 13th by date. The first argument is the sequence to group, and the second is a function that, given an item, returns the key of that item that we want to group by.

For the key in this case, use operator.attrgetter() as a convenient way to create a callable that, when given a date, returns date.year.

Similarly to how we used list() above, use dict() to transform the (key, value) tuples (returned by the groupby iterable) into a dictionary.

In [11]:
from itertools import groupby
from operator import attrgetter

def friday_13_by_year():
    return groupby(friday_13_before_max(), attrgetter('year'))

dict(friday_13_by_year())
Out[11]:
{2013: <itertools._grouper at 0x10e414b90>,
 2014: <itertools._grouper at 0x10e414cd0>,
 2015: <itertools._grouper at 0x10e414c50>,
 2016: <itertools._grouper at 0x10e414550>,
 2017: <itertools._grouper at 0x10e414d50>,
 2018: <itertools._grouper at 0x10e414c90>,
 2019: <itertools._grouper at 0x10e414ed0>,
 2020: <itertools._grouper at 0x10e414f50>,
 2021: <itertools._grouper at 0x10e414f10>,
 2022: <itertools._grouper at 0x10e414f90>,
 2023: <itertools._grouper at 0x10e414e90>,
 2024: <itertools._grouper at 0x10e414d90>,
 2025: <itertools._grouper at 0x10e414dd0>,
 2026: <itertools._grouper at 0x10e414e10>,
 2027: <itertools._grouper at 0x10e414e50>,
 2028: <itertools._grouper at 0x10e414fd0>,
 2029: <itertools._grouper at 0x10e417050>,
 2030: <itertools._grouper at 0x10e417090>,
 2031: <itertools._grouper at 0x10e4170d0>,
 2032: <itertools._grouper at 0x10e417110>,
 2033: <itertools._grouper at 0x10e417150>}

The above output isn’t useful though, because each value in the (key, value) pair is a “lazy” iterable object itself. All we see is the repr() of those iterables, and not the dates we’re looking for.

Use a generator expression to convert the value of each pair into a list:

In [12]:
dict((key, list(dates)) for key, dates in friday_13_by_year())
Out[12]:
{2013: [datetime.date(2013, 9, 13), datetime.date(2013, 12, 13)],
 2014: [datetime.date(2014, 6, 13)],
 2015: [datetime.date(2015, 2, 13),
  datetime.date(2015, 3, 13),
  datetime.date(2015, 11, 13)],
 2016: [datetime.date(2016, 5, 13)],
 2017: [datetime.date(2017, 1, 13), datetime.date(2017, 10, 13)],
 2018: [datetime.date(2018, 4, 13), datetime.date(2018, 7, 13)],
 2019: [datetime.date(2019, 9, 13), datetime.date(2019, 12, 13)],
 2020: [datetime.date(2020, 3, 13), datetime.date(2020, 11, 13)],
 2021: [datetime.date(2021, 8, 13)],
 2022: [datetime.date(2022, 5, 13)],
 2023: [datetime.date(2023, 1, 13), datetime.date(2023, 10, 13)],
 2024: [datetime.date(2024, 9, 13), datetime.date(2024, 12, 13)],
 2025: [datetime.date(2025, 6, 13)],
 2026: [datetime.date(2026, 2, 13),
  datetime.date(2026, 3, 13),
  datetime.date(2026, 11, 13)],
 2027: [datetime.date(2027, 8, 13)],
 2028: [datetime.date(2028, 10, 13)],
 2029: [datetime.date(2029, 4, 13), datetime.date(2029, 7, 13)],
 2030: [datetime.date(2030, 9, 13), datetime.date(2030, 12, 13)],
 2031: [datetime.date(2031, 6, 13)],
 2032: [datetime.date(2032, 2, 13), datetime.date(2032, 8, 13)],
 2033: [datetime.date(2033, 5, 13)]}

That’s a little better, but is still very busy.

Use calendar.monthname to show just the names of the months containing a Friday the 13th each year.

In [13]:
import calendar

dict(
    (year, [calendar.month_name[date.month] for date in dates]) 
    for year, dates 
    in friday_13_by_year()
)
Out[13]:
{2013: ['September', 'December'],
 2014: ['June'],
 2015: ['February', 'March', 'November'],
 2016: ['May'],
 2017: ['January', 'October'],
 2018: ['April', 'July'],
 2019: ['September', 'December'],
 2020: ['March', 'November'],
 2021: ['August'],
 2022: ['May'],
 2023: ['January', 'October'],
 2024: ['September', 'December'],
 2025: ['June'],
 2026: ['February', 'March', 'November'],
 2027: ['August'],
 2028: ['October'],
 2029: ['April', 'July'],
 2030: ['September', 'December'],
 2031: ['June'],
 2032: ['February', 'August'],
 2033: ['May']}

During which years will each calendar month have Friday the 13th?

Just like we used attrgetter('year') to find the months in each year, use attrgetter('month') to find out which years a given month will have a Friday the 13th.

Just as above, we can anticipate transforming our output, this time showing only the .year of a given date rather than the .month.

In [14]:
def friday_13_dates_by_month():
    return groupby(friday_13_before_max(), attrgetter('month'))

def years_by_month(dates_by_month):
    return dict(
        (month, [date.year for date in dates])
        for month, dates
        in dates_by_month
    )

years_by_month(friday_13_dates_by_month())
Out[14]:
{1: [2023],
 2: [2032],
 3: [2026],
 4: [2029],
 5: [2033],
 6: [2031],
 7: [2029],
 8: [2032],
 9: [2030],
 10: [2028],
 11: [2026],
 12: [2030]}

This isn’t correct though! All we see is the most recent year, instead of a list of each matching year.

What happened?

In the groupby() documentation, it mentions that the input sequence should generally be sorted by the same key you are using for grouping.

Here’s what it looks like before we turn it into a dictionary. A lot of the keys are repeated, because the sort order of the input is by (year, month, day) instead of by month:

In [15]:
list(friday_13_dates_by_month())
Out[15]:
[(9, <itertools._grouper at 0x10e414310>),
 (12, <itertools._grouper at 0x10e414a10>),
 (6, <itertools._grouper at 0x10e4144d0>),
 (2, <itertools._grouper at 0x10e4149d0>),
 (3, <itertools._grouper at 0x10e414d10>),
 (11, <itertools._grouper at 0x10e414ad0>),
 (5, <itertools._grouper at 0x10e414b50>),
 (1, <itertools._grouper at 0x10e414a90>),
 (10, <itertools._grouper at 0x10e414850>),
 (4, <itertools._grouper at 0x10e414810>),
 (7, <itertools._grouper at 0x10e417ad0>),
 (9, <itertools._grouper at 0x10e4178d0>),
 (12, <itertools._grouper at 0x10e417410>),
 (3, <itertools._grouper at 0x10e417390>),
 (11, <itertools._grouper at 0x10e417490>),
 (8, <itertools._grouper at 0x10e417590>),
 (5, <itertools._grouper at 0x10e417610>),
 (1, <itertools._grouper at 0x10e417550>),
 (10, <itertools._grouper at 0x10e417450>),
 (9, <itertools._grouper at 0x10e4172d0>),
 (12, <itertools._grouper at 0x10e417290>),
 (6, <itertools._grouper at 0x10e417510>),
 (2, <itertools._grouper at 0x10e417690>),
 (3, <itertools._grouper at 0x10e417710>),
 (11, <itertools._grouper at 0x10e4179d0>),
 (8, <itertools._grouper at 0x10e417a10>),
 (10, <itertools._grouper at 0x10e417850>),
 (4, <itertools._grouper at 0x10e417a90>),
 (7, <itertools._grouper at 0x10e4175d0>),
 (9, <itertools._grouper at 0x10e417b10>),
 (12, <itertools._grouper at 0x10e417990>),
 (6, <itertools._grouper at 0x10e417790>),
 (2, <itertools._grouper at 0x10e4177d0>),
 (8, <itertools._grouper at 0x10e417890>),
 (5, <itertools._grouper at 0x10e417650>)]

You should make sure your input data is pre-sorted as the Python docs recommend. Only deviate from that when you have one of those rare, specific cases where it makes sense to do otherwise.

Factor out attrgetter('month') into by_month, then use that to sort the dates by month before grouping them:

In [16]:
by_month = attrgetter('month')

def friday_13_dates_by_month():
    dates = friday_13_before_max()
    sorted_dates = sorted(dates, key=by_month)
    return groupby(sorted_dates, by_month)

years_by_month(friday_13_dates_by_month())
Out[16]:
{1: [2017, 2023],
 2: [2015, 2026, 2032],
 3: [2015, 2020, 2026],
 4: [2018, 2029],
 5: [2016, 2022, 2033],
 6: [2014, 2025, 2031],
 7: [2018, 2029],
 8: [2021, 2027, 2032],
 9: [2013, 2019, 2024, 2030],
 10: [2017, 2023, 2028],
 11: [2015, 2020, 2026],
 12: [2013, 2019, 2024, 2030]}

That’s better!

Let’s make one final change, which is to add an optional years argument. Here we use it to look ahead 75 years:

In [17]:
def friday_13_dates_by_month(years=YEARS):
    dates = friday_13_before_max(years_from(START, years))
    sorted_dates = sorted(dates, key=by_month)
    return groupby(sorted_dates, by_month)

years_by_month(friday_13_dates_by_month(years=75))
Out[17]:
{1: [2017, 2023, 2034, 2040, 2045, 2051, 2062, 2068, 2073, 2079],
 2: [2015, 2026, 2032, 2037, 2043, 2054, 2060, 2065, 2071, 2082, 2088],
 3: [2015, 2020, 2026, 2037, 2043, 2048, 2054, 2065, 2071, 2076, 2082],
 4: [2018, 2029, 2035, 2040, 2046, 2057, 2063, 2068, 2074, 2085],
 5: [2016, 2022, 2033, 2039, 2044, 2050, 2061, 2067, 2072, 2078],
 6: [2014, 2025, 2031, 2036, 2042, 2053, 2059, 2064, 2070, 2081, 2087],
 7: [2018, 2029, 2035, 2040, 2046, 2057, 2063, 2068, 2074, 2085],
 8: [2021, 2027, 2032, 2038, 2049, 2055, 2060, 2066, 2077, 2083, 2088],
 9: [2013, 2019, 2024, 2030, 2041, 2047, 2052, 2058, 2069, 2075, 2080, 2086],
 10: [2017, 2023, 2028, 2034, 2045, 2051, 2056, 2062, 2073, 2079, 2084],
 11: [2015, 2020, 2026, 2037, 2043, 2048, 2054, 2065, 2071, 2076, 2082],
 12: [2013, 2019, 2024, 2030, 2041, 2047, 2052, 2058, 2069, 2075, 2080, 2086]}

What does the distribution of Friday the 13th look like over the next 100 years?

One of the many third-party modules available to enhance IPython Notebook is ipythonblocks. It’s a straightforward way to create a 2-dimensional grid, where each cell in the grid is assigned a color.

You can use ipythonblocks it to demonstrate or practice with various flow control structures, or to visualize certain algorithms or formulae.

Install it using pip:

In [18]:
!pip install -q ipythonblocks

Start off with an empty grid. The grid here is filled with the color Red and has some other basic styling changes applied.

Create a color_dates() function that accepts a sequence of dates, and a color to apply to the grid cells that represent those dates.

Note that we pass in our color as *color when we call a cell’s set_colors() method. This is because set_colors() wants three arguments — red, green, and blue — while the color is a (red, green, blue) tuple.

Using *color will transform that single tuple into the three separate arguments that will satisfy set_colors().

Color every Friday the 13th with Black.

In [19]:
from ipythonblocks import BlockGrid, colors

# Make the grid 101 blocks tall since we're including this year (year 0) 
# through 100 years from now (year 100)
grid = BlockGrid(12, 101, fill=colors['Red'], block_size=10, lines_on=False)

def color_dates(dates, color):
    for date in dates:
        row = date.year - START.year
        col = date.month - 1  # Months are 1-based, grid is 0-based
        grid[row, col].set_colors(*color)

color_dates(friday_13_before_max(years_from(START, 100)), colors['Black'])
        
grid
Out[19]:

For fun, let’s also find all of the Friday the 13th days that occur in October.

Start by using our existing friday_13_dates_by_month() iterator. Turn it into a dictionary, then grab the value corresponding to month 10:

In [20]:
all_dates = dict(friday_13_dates_by_month(years=100))
october_dates = all_dates[10]

october_dates
Out[20]:
<itertools._grouper at 0x10e409dd0>

As expected, it’s another iterable. Convert it to a list to find out what it returns:

In [21]:
list(october_dates)
Out[21]:
[]

An empty list is not what we expected!

This goes back to another side-effect of groupby() that’s also described in the docs: When the outer iterable is advanced, the previous group is no longer visible, even though the inner iterable representing that group still exists.

Refactor friday_13_dates_by_month() to do this conversion for us as each (key, iterable) is returned by the outer iterable:

In [22]:
def friday_13_dates_by_month(years=YEARS):
    dates = friday_13_before_max(years_from(START, years))
    sorted_dates = sorted(dates, key=by_month)
    grouped = groupby(sorted_dates, by_month)
    return ((month, list(dates)) for month, dates in grouped)

all_dates = dict(friday_13_dates_by_month(years=100))
october_dates = all_dates[10]

october_dates
Out[22]:
[datetime.date(2017, 10, 13),
 datetime.date(2023, 10, 13),
 datetime.date(2028, 10, 13),
 datetime.date(2034, 10, 13),
 datetime.date(2045, 10, 13),
 datetime.date(2051, 10, 13),
 datetime.date(2056, 10, 13),
 datetime.date(2062, 10, 13),
 datetime.date(2073, 10, 13),
 datetime.date(2079, 10, 13),
 datetime.date(2084, 10, 13),
 datetime.date(2090, 10, 13),
 datetime.date(2102, 10, 13),
 datetime.date(2113, 10, 13)]

Finally, let’s color them orange!

In [23]:
color_dates(october_dates, colors['Orange'])

grid
Out[23]:

Conclusion

To explore Friday the 13th, we used a “mostly functional” approach, which Python is well-suited for in many kinds of programming situations.

We also explored some useful standard library modules:

  • datetime for working with dates.
  • itertools for slicing a range, taking while a predicate is true, and grouping by a key.
  • operator for creating callable key functions.

Finally, we took a very quick look at ipythonblocks, a simple and straightforward way to visualize data in the IPython Notebook.

I hope you found this interesting and informative.

Do you have questions, comments, suggestions, or further insight? Your feedback is appreciated!


Comments

Do you like podcasts? Check out Fanscribed. It helps podcasts use crowdsourcing to create high-quality transcripts.

Fanscribed is created and maintained by Elevencraft Inc.


I'm Matthew Scott, and my company Elevencraft Inc. exists to help you wisely use tech to solve problems:

  • "Full stack" development
  • Pair-programming
  • Python and Django support
  • Modern dev tools, like git

Ready to find out how we can work together? Email me at matthew@11craft.com


Elevencraft Inc.
Springfield, MO, USA
matthew@11craft.com
+1 360 389-2512