<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Data Engineering on Max Woolf&#39;s Blog</title>
    <link>https://minimaxir.com/category/data-engineering/</link>
    <description>Recent content in Data Engineering on Max Woolf&#39;s Blog</description>
    <image>
      <title>Max Woolf&#39;s Blog</title>
      <url>https://minimaxir.com/android-chrome-512x512.png</url>
      <link>https://minimaxir.com/android-chrome-512x512.png</link>
    </image>
    <generator>Hugo</generator>
    <language>en</language>
    <copyright>Copyright Max Woolf © 2026</copyright>
    <lastBuildDate>Wed, 23 Oct 2019 09:00:00 -0700</lastBuildDate>
    <atom:link href="https://minimaxir.com/category/data-engineering/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Visualizing Airline Flight Characteristics Between SFO and JFK</title>
      <link>https://minimaxir.com/2019/10/sfo-jfk-flights/</link>
      <pubDate>Wed, 23 Oct 2019 09:00:00 -0700</pubDate>
      <guid>https://minimaxir.com/2019/10/sfo-jfk-flights/</guid>
      <description>Box plots, when used correctly, can be a very fun way to visualize big data.</description>
      <content:encoded><![CDATA[<p>In March, <a href="https://cloud.google.com">Google Compute Platform</a> developer advocate <a href="https://twitter.com/felipehoffa">Felipe Hoffa</a> made a tweet about airline flight data from San Francisco International Airport (SFO) to Seattle-Tacoma International Airport (SEA):</p>
<blockquote class="twitter-tweet">
  <a href="https://twitter.com/felipehoffa/status/1111050585120206848"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<p>Particularly, his visualization of total elapsed times by airline caught my eye.</p>
<figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/D2s9oFtX4AEK6nD_hu_33d3683c2d4a611e.webp 320w,/2019/10/sfo-jfk-flights/D2s9oFtX4AEK6nD_hu_1c609cadbe91671c.webp 768w,/2019/10/sfo-jfk-flights/D2s9oFtX4AEK6nD_hu_3135cb9a9bbaf839.webp 1024w,/2019/10/sfo-jfk-flights/D2s9oFtX4AEK6nD.jpeg 1200w" src="D2s9oFtX4AEK6nD.jpeg"/> 
</figure>

<p>The overall time for flights from SFO to SEA goes up drastically starting in 2015, and this increase occurs across multiple airlines, implying that it&rsquo;s not an airline-specific problem. But what could intuitively cause that?</p>
<p>U.S. domestic airline data is <a href="https://www.transtats.bts.gov/Tables.asp?DB_ID=120">freely distributed</a> by the United States Department of Transportation. Normally it&rsquo;s a pain to work with as it&rsquo;s very large with millions of rows, but BigQuery makes playing with such data relatively easy, fun, and free. What other interesting factoids can be found?</p>
<h2 id="expanding-on-sfo--sea">Expanding on SFO → SEA</h2>
<p><a href="https://cloud.google.com/bigquery/">BigQuery</a> is a big data warehousing tool that allows you to query massive amounts of data. The table Hoffa created from the airline data (<code>fh-bigquery.flights.ontime_201903</code>) is 83.37 GB and 184 <em>million</em> rows. You can query 1 TB of data from it for free, but since BQ will only query against the fields you request, the queries in this post only consume about 2 GB each, allowing you to run them well within that quota.</p>
<p>Hoffa&rsquo;s query that runs on BigQuery looks like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">FlightDate_year</span><span class="p">,</span><span class="w"> </span><span class="n">Reporting_Airline</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">,</span><span class="w"> </span><span class="k">AVG</span><span class="p">(</span><span class="n">ActualElapsedTime</span><span class="p">)</span><span class="w"> </span><span class="n">ActualElapsedTime</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">,</span><span class="w"> </span><span class="k">AVG</span><span class="p">(</span><span class="n">TaxiOut</span><span class="p">)</span><span class="w"> </span><span class="n">TaxiOut</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">,</span><span class="w"> </span><span class="k">AVG</span><span class="p">(</span><span class="n">TaxiIn</span><span class="p">)</span><span class="w"> </span><span class="n">TaxiIn</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">,</span><span class="w"> </span><span class="k">AVG</span><span class="p">(</span><span class="n">AirTime</span><span class="p">)</span><span class="w"> </span><span class="n">AirTime</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">,</span><span class="w"> </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">c</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="o">`</span><span class="n">fh</span><span class="o">-</span><span class="n">bigquery</span><span class="p">.</span><span class="n">flights</span><span class="p">.</span><span class="n">ontime_201903</span><span class="o">`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">WHERE</span><span class="w"> </span><span class="n">Origin</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;SFO&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">AND</span><span class="w"> </span><span class="n">Dest</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;SEA&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">AND</span><span class="w"> </span><span class="n">FlightDate_year</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="s1">&#39;2010-01-01&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="mi">2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="mi">1</span><span class="w"> </span><span class="k">DESC</span><span class="p">,</span><span class="w"> </span><span class="mi">3</span><span class="w"> </span><span class="k">DESC</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">LIMIT</span><span class="w"> </span><span class="mi">1000</span><span class="w">
</span></span></span></code></pre></div><p>For each year and airline after 2010, the query calculates the average metrics specified for flights on the SFO → SEA route.</p>
<p>I made a few query and data visualization tweaks to what Hoffa did above, and here&rsquo;s the result showing the increase in elapsed airline flight time, over time for that route:</p>
<figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/sfo_sea_flight_duration_hu_e232d6eeab7fb66.webp 320w,/2019/10/sfo-jfk-flights/sfo_sea_flight_duration_hu_948de6a062caeaca.webp 768w,/2019/10/sfo-jfk-flights/sfo_sea_flight_duration_hu_6ae123a09b30ff70.webp 1024w,/2019/10/sfo-jfk-flights/sfo_sea_flight_duration.png 1800w" src="sfo_sea_flight_duration.png"/> 
</figure>

<p>Let&rsquo;s explain what&rsquo;s going on here.</p>
<p>A common trend in statistics is avoiding using <a href="https://en.wikipedia.org/wiki/Average">averages</a> as a summary statistic whenever possible, as averages can be overly affected by strong outliers (and with airline flights, there are definitely strong outliers!). The solution is to use a <a href="https://en.wikipedia.org/wiki/Median">median</a> instead, but one problem: medians are hard and <a href="https://www.periscopedata.com/blog/medians-in-sql">computationally complex</a> to calculate compared to simple averages. Despite the rise of &ldquo;big data&rdquo;, most databases and BI tools don&rsquo;t have a <code>MEDIAN</code> function that&rsquo;s as easy to use as an <code>AVG</code> function. But BigQuery has an uncommon <a href="https://cloud.google.com/bigquery/docs/reference/standard-sql/approximate_aggregate_functions#approx_quantiles">APPROX_QUANTILES</a> function, which calculates the specified amount of quantiles; for example, if you call <code>APPROX_QUANTILES(ActualElapsedTime, 100)</code>, it will return an array with the 100 quantiles, where the median will be the 50th quantile. BigQuery <a href="https://cloud.google.com/bigquery/docs/reference/standard-sql/approximate-aggregation">uses</a> an algorithmic trick called <a href="https://en.wikipedia.org/wiki/HyperLogLog">HyperLogLog++</a> to calculate these quantiles efficiently even with millions of data points. But since we get other quantiles like the 5th, 25th, 75th, and 95th quantiles for free with that approach, we can visualize the <em>spread</em> of the data.</p>
<p>We can aggregate the data by month for more granular trends and calculate the <code>APPROX_QUANTILES</code> in a subquery so it only has to be computed once. Hoffa also uploaded a more recent table (<code>fh-bigquery.flights.ontime_201908</code>) with a few additional months of data. To make things more simple, we&rsquo;ll ignore aggregating by airlines since the metrics do not vary strongly between them. The final query ends up looking like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="o">#</span><span class="n">standardSQL</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">Year</span><span class="p">,</span><span class="w"> </span><span class="k">Month</span><span class="p">,</span><span class="w"> </span><span class="n">num_flights</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="n">time_q</span><span class="p">[</span><span class="k">OFFSET</span><span class="p">(</span><span class="mi">5</span><span class="p">)]</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">q_5</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="n">time_q</span><span class="p">[</span><span class="k">OFFSET</span><span class="p">(</span><span class="mi">25</span><span class="p">)]</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">q_25</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="n">time_q</span><span class="p">[</span><span class="k">OFFSET</span><span class="p">(</span><span class="mi">50</span><span class="p">)]</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">q_50</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="n">time_q</span><span class="p">[</span><span class="k">OFFSET</span><span class="p">(</span><span class="mi">75</span><span class="p">)]</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">q_75</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="n">time_q</span><span class="p">[</span><span class="k">OFFSET</span><span class="p">(</span><span class="mi">95</span><span class="p">)]</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">q_95</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">Year</span><span class="p">,</span><span class="w"> </span><span class="k">Month</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">num_flights</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">APPROX_QUANTILES</span><span class="p">(</span><span class="n">ActualElapsedTime</span><span class="p">,</span><span class="w"> </span><span class="mi">100</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">time_q</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="o">`</span><span class="n">fh</span><span class="o">-</span><span class="n">bigquery</span><span class="p">.</span><span class="n">flights</span><span class="p">.</span><span class="n">ontime_201908</span><span class="o">`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">WHERE</span><span class="w"> </span><span class="n">Origin</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;SFO&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">AND</span><span class="w"> </span><span class="n">Dest</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;SEA&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">AND</span><span class="w"> </span><span class="n">FlightDate_year</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="s1">&#39;2010-01-01&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">Year</span><span class="p">,</span><span class="w"> </span><span class="k">Month</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="k">Year</span><span class="p">,</span><span class="w"> </span><span class="k">Month</span><span class="w">
</span></span></span></code></pre></div><p>The resulting data table:</p>
<figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/table_hu_98a96a00ebd58c2c.webp 320w,/2019/10/sfo-jfk-flights/table_hu_9eddda8c57624a2.webp 768w,/2019/10/sfo-jfk-flights/table.png 932w" src="table.png"/> 
</figure>

<p>In retrospect, since we&rsquo;re only focusing on one route, it isn&rsquo;t <em>big</em> data (this query only returns data on 64,356 flights total), but it&rsquo;s still a very useful skill if you need to analyze more of the airline data (the <code>APPROX_QUANTILES</code> function can handle <em>millions</em> of data points very quickly).</p>
<p>As a professional data scientist, one of my favorite types of data visualization is a <a href="https://en.wikipedia.org/wiki/Box_plot">box plot</a>, as it provides a way to visualize spread without being visually intrusive. Data visualization tools like <a href="https://www.r-project.org">R</a> and <a href="https://ggplot2.tidyverse.org/index.html">ggplot2</a> make constructing them <a href="https://ggplot2.tidyverse.org/reference/geom_boxplot.html">very easy to do</a>.</p>
<figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/geom_boxplot-1_hu_9a623aa679dafed1.webp 320w,/2019/10/sfo-jfk-flights/geom_boxplot-1_hu_67cf70ba510d1672.webp 768w,/2019/10/sfo-jfk-flights/geom_boxplot-1_hu_c405dbc443ae9fa8.webp 1024w,/2019/10/sfo-jfk-flights/geom_boxplot-1.png 1400w" src="geom_boxplot-1.png"/> 
</figure>

<p>By default, for each box representing a group, the thick line in the middle of the box is the median, the lower bound of the box is the 25th quantile and the upper bound is the 75th quantile. The whiskers are normally a function of the <a href="https://en.wikipedia.org/wiki/Interquartile_range">interquartile range</a> (IQR), but if there&rsquo;s enough data, I prefer to use the 5th and 95th quantiles instead.</p>
<p>If you feed ggplot2&rsquo;s <code>geom_boxplot()</code> with raw data, it will automatically calculate the corresponding metrics for visualization; however, with big data, the data may not fit into memory and as noted earlier, medians and other quantiles are computationally expensive to calculate. Because we precomputed the quantiles with the query above for every year and month, we can use those explicitly. (The minor downside is that this will not include outliers)</p>
<p>Additionally for box plots, I like to fill in each box with a different color corresponding to the year in order to better perceive data <a href="https://en.wikipedia.org/wiki/Seasonality">seasonality</a>. In the case of airline flights, seasonality is more literal: weather has an intuitive impact on flight times and delays, and during winter months there are also holidays which could affect airline logistics.</p>
<p>The resulting ggplot2 code looks like this:</p>
<pre tabindex="0"><code>plot &lt;-
  ggplot(df_tf,
         aes(
           x = date,
           ymin = q_5,
           lower = q_25,
           middle = q_50,
           upper = q_75,
           ymax = q_95,
           group = date,
           fill = year_factor
         )) +
  geom_boxplot(stat = &#34;identity&#34;, size = 0.3) +
  scale_fill_hue(l = 50, guide = F) +
  scale_x_date(date_breaks = &#39;1 year&#39;, date_labels = &#34;%Y&#34;) +
  scale_y_continuous(breaks = pretty_breaks(6)) +
  labs(
    title = &#34;Distribution of Flight Times of Flights From SFO → SEA, by Month&#34;,
    subtitle = &#34;via US DoT. Box bounds are 25th/75th percentiles, whiskers are 5th/95th percentiles.&#34;,
    y = &#39;Total Elapsed Flight Time (Minutes)&#39;,
    fill = &#39;&#39;,
    caption = &#39;Max Woolf — minimaxir.com&#39;
  ) +
  theme(axis.title.x = element_blank())

ggsave(&#39;sfo_sea_flight_duration.png&#39;,
       plot,
       width = 6,
       height = 4)
</code></pre><p>And behold (again)!</p>
<figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/sfo_sea_flight_duration_hu_e232d6eeab7fb66.webp 320w,/2019/10/sfo-jfk-flights/sfo_sea_flight_duration_hu_948de6a062caeaca.webp 768w,/2019/10/sfo-jfk-flights/sfo_sea_flight_duration_hu_6ae123a09b30ff70.webp 1024w,/2019/10/sfo-jfk-flights/sfo_sea_flight_duration.png 1800w" src="sfo_sea_flight_duration.png"/> 
</figure>

<p>You can see that the boxes do indeed trend upward after 2016, although per-month medians are in flux. The spread is also increasingly slowly over time. But what&rsquo;s interesting is the seasonality; pre-2016, the summer months (the &ldquo;middle&rdquo; of a given color) have a <em>very</em> significant drop in total time, which doesn&rsquo;t occur as strongly after 2016. Hmm.</p>
<h2 id="sfo-and-jfk">SFO and JFK</h2>
<p>Since I occasionally fly from San Francisco to New York City, it might be interesting (for completely selfish reasons) to track trends over time for flights between those areas. On the San Francisco side I choose SFO, and for the New York side I choose John F. Kennedy International Airport (JFK), as the data goes back very far for those routes specifically, and I only want to look at a single airport at a time (instead of including other NYC airports such as Newark Liberty International Airport [EWR] and LaGuardia Airport [LGA]) to limit potential data confounders.</p>
<p>Fortunately, the code and query changes are minimal: in the query, change the target metric to whatever metric you want, and the <code>Origin</code> and <code>Dest</code> in the <code>WHERE</code> clause to what you want, and if you want to calculate metrics other than elapsed time, change the metric in <code>APPROX_QUANTILES</code> accordingly.</p>
<p>Here&rsquo;s the chart of total elapsed time from SFO → JFK:</p>
<figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/sfo_jfk_flight_duration_hu_230bbe279f54a805.webp 320w,/2019/10/sfo-jfk-flights/sfo_jfk_flight_duration_hu_c2e4a5d4b43ce24e.webp 768w,/2019/10/sfo-jfk-flights/sfo_jfk_flight_duration_hu_2ea286d0e1e5d794.webp 1024w,/2019/10/sfo-jfk-flights/sfo_jfk_flight_duration.png 1800w" src="sfo_jfk_flight_duration.png"/> 
</figure>

<p>And here&rsquo;s the reverse, from JFK → SFO:</p>
<figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/jfk_sfo_flight_duration_hu_4424fffe053981c8.webp 320w,/2019/10/sfo-jfk-flights/jfk_sfo_flight_duration_hu_ace5c5c4f6b82a9a.webp 768w,/2019/10/sfo-jfk-flights/jfk_sfo_flight_duration_hu_5d29021a8362404b.webp 1024w,/2019/10/sfo-jfk-flights/jfk_sfo_flight_duration.png 1800w" src="jfk_sfo_flight_duration.png"/> 
</figure>

<p>Unlike the SFO → SEA charts, both charts are relatively flat over the years. However, when looking at seasonality, SFO → JFK dips in the summer and spikes during winter, while JFK → SFO <em>does the complete opposite</em>: dips during the winter and spikes during the summer, which is similar to the SFO → SEA route. I don&rsquo;t have any guesses what would cause that behavior.</p>
<p>How about flight speed (calculated via air time divided by distance)? Have new advances in airline technology made planes faster and/or more efficient?</p>
<p><figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/sfo_jfk_flight_speed_hu_9bbb991fb8674a3f.webp 320w,/2019/10/sfo-jfk-flights/sfo_jfk_flight_speed_hu_d4b14a4133ff0b82.webp 768w,/2019/10/sfo-jfk-flights/sfo_jfk_flight_speed_hu_7266f1a8d449775b.webp 1024w,/2019/10/sfo-jfk-flights/sfo_jfk_flight_speed.png 1800w" src="sfo_jfk_flight_speed.png"/> 
</figure>

<figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/jfk_sfo_flight_speed_hu_86e7c997338f1404.webp 320w,/2019/10/sfo-jfk-flights/jfk_sfo_flight_speed_hu_1680890adf0e2d82.webp 768w,/2019/10/sfo-jfk-flights/jfk_sfo_flight_speed_hu_942e26ae57610365.webp 1024w,/2019/10/sfo-jfk-flights/jfk_sfo_flight_speed.png 1800w" src="jfk_sfo_flight_speed.png"/> 
</figure>
</p>
<p>The expected flight speed for a commercial airplane, <a href="https://en.wikipedia.org/wiki/Cruise_%28aeronautics%29">per Wikipedia</a>, is 547-575 mph, so the metrics from SFO pass the sanity check. The metrics from JFK indicate there&rsquo;s about a 20% drop in flight speed potentially due to wind resistance, which makes sense. Month-to-month, the speed trends are inverse to the total elapsed time, which makes sense intuitively as they are strongly negatively correlated.</p>
<p>Lastly, what about flight departure delays? Are airlines becoming more efficient, or has increased demand caused more congestion?</p>
<figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/sfo_jfk_departure_delay_hu_82c27db5d16562f9.webp 320w,/2019/10/sfo-jfk-flights/sfo_jfk_departure_delay_hu_b017086eec0a8d63.webp 768w,/2019/10/sfo-jfk-flights/sfo_jfk_departure_delay_hu_3a8b126a0bfc0d76.webp 1024w,/2019/10/sfo-jfk-flights/sfo_jfk_departure_delay.png 1800w" src="sfo_jfk_departure_delay.png"/> 
</figure>

<p>Wait a second. In this case, massive 2-3 hour flight delays are frequent enough that even just the 95% percentile skews the entire plot. Let&rsquo;s remove the whiskers in order to look at trends more clearly.</p>
<p><figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/sfo_jfk_departure_delay_nowhiskers_hu_c2eb7d1ad6cdf7.webp 320w,/2019/10/sfo-jfk-flights/sfo_jfk_departure_delay_nowhiskers_hu_86b737333ad479f4.webp 768w,/2019/10/sfo-jfk-flights/sfo_jfk_departure_delay_nowhiskers_hu_fd6ad349f57f4bbe.webp 1024w,/2019/10/sfo-jfk-flights/sfo_jfk_departure_delay_nowhiskers.png 1800w" src="sfo_jfk_departure_delay_nowhiskers.png"/> 
</figure>

<figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/jfk_sfo_departure_delay_nowhiskers_hu_1fecf180ed6a5feb.webp 320w,/2019/10/sfo-jfk-flights/jfk_sfo_departure_delay_nowhiskers_hu_626df458859e27b7.webp 768w,/2019/10/sfo-jfk-flights/jfk_sfo_departure_delay_nowhiskers_hu_58e7e7ba605d269e.webp 1024w,/2019/10/sfo-jfk-flights/jfk_sfo_departure_delay_nowhiskers.png 1800w" src="jfk_sfo_departure_delay_nowhiskers.png"/> 
</figure>
</p>
<p>A negative delay implies the flight leaves early, so we can conclude on average, flights leave slightly earlier than the stated departure time. Even without the whiskers, we can see major spikes at the 75th percentile level for summer months, and said spikes were especially bad in 2017 for both airports.</p>
<p>These box plots are only an <a href="https://en.wikipedia.org/wiki/Exploratory_data_analysis">exploratory data analysis</a>. Determining the <em>cause</em> of changes in these flight metrics is difficult even for experts (I am definitely not an expert!) and many not even be possible to determine from publicly-available data.</p>
<p>But there are still other fun things that can be done with the airline flight data, such as faceting airline trends by time and the inclusion of other airports, which is <a href="https://twitter.com/minimaxir/status/1115261670153048065"><em>interesting</em></a>.</p>
<hr>
<p><em>You can view the BigQuery queries used to get the data, plus the R and ggplot2 used to create the data visualizations, in <a href="http://minimaxir.com/notebooks/sfo-jfk-flights/">this R Notebook</a>. You can also view the images/code used for this post in <a href="https://github.com/minimaxir/sfo-jfk-flights">this GitHub repository</a></em>.</p>
<p><em>You are free to use the data visualizations from this article however you wish, but it would be greatly appreciated if proper attribution is given to this article and/or myself!</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Problems with Predicting Post Performance on Reddit and Other Link Aggregators</title>
      <link>https://minimaxir.com/2018/09/modeling-link-aggregators/</link>
      <pubDate>Mon, 10 Sep 2018 09:15:00 -0700</pubDate>
      <guid>https://minimaxir.com/2018/09/modeling-link-aggregators/</guid>
      <description>The nature of algorithmic feeds like Reddit inherently leads to a survivorship bias: although users may recognize certain types of posts that appear on the front page, there are many more which follow the same patterns but fail.</description>
      <content:encoded><![CDATA[<p><a href="https://www.reddit.com">Reddit</a>, &ldquo;the front page of the internet&rdquo; is a link aggregator where anyone can submit links to cool happenings. Over the years, Reddit has expanded from just being a link aggregator, to allowing image and videos, and as of recently, hosting images and videos itself.</p>
<p>Reddit is broken down into subreddits, where each subreddit represents each own community around a particular interest, like <a href="https://www.reddit.com/r/aww">/r/aww</a> for pet photos and <a href="https://www.reddit.com/r/politics/">/r/politics</a> for U.S. politics. The posts on each subreddit are ranked by some function of both time elapsed since the submission was made, and the <em>score</em> of the submission as determined by upvotes and downvotes from other users.</p>
<figure>

    <img loading="lazy" srcset="/2018/09/modeling-link-aggregators/reddit_aww_hu_15514c9daececa75.webp 320w,/2018/09/modeling-link-aggregators/reddit_aww_hu_38fdc85d80e9f49f.webp 768w,/2018/09/modeling-link-aggregators/reddit_aww.png 827w" src="reddit_aww.png"/> 
</figure>

<p>There&rsquo;s also an intrinsic pride in having something you&rsquo;re responsible for providing to the community get lots of upvotes (the submitter also earns karma based on received upvotes, although karma is meaningless and doesn&rsquo;t provide any user benefits). But the reality is that even on the largest subreddits, submissions with 1 point (the default score for new submissions) are the most prominent, with some subreddits having <em>over half</em> of their submissions with only 1 point.</p>
<figure>

    <img loading="lazy" srcset="/2018/09/modeling-link-aggregators/reddit_dist_facet_hu_94559d39f676be08.webp 320w,/2018/09/modeling-link-aggregators/reddit_dist_facet_hu_ede8ccaaf5538573.webp 768w,/2018/09/modeling-link-aggregators/reddit_dist_facet_hu_940890d5e65baccb.webp 1024w,/2018/09/modeling-link-aggregators/reddit_dist_facet.png 1800w" src="reddit_dist_facet.png"/> 
</figure>

<p>The exposure from having a submission go viral on Reddit (especially on larger subreddits) can be valuable especially if its your own original content. As a result, there has been a lot of <a href="https://www.brandwatch.com/blog/how-to-get-on-the-front-page-of-reddit/">analysis</a>/<a href="https://www.reddit.com/r/starterpacks/comments/8rkfk9/reddit_front_page_starter_pack/">stereotypes</a> on what techniques to do to help your submission make it to the top of the front page. But almost all claims of &ldquo;cracking&rdquo; the Reddit algorithm are <a href="https://en.wikipedia.org/wiki/Post_hoc_ergo_propter_hoc"><em>post hoc</em> rationalizations</a>, attributing success to things like submission timing and title verbiage of a single submission after the fact. The nature of algorithmic feeds inherently leads to a <a href="https://en.wikipedia.org/wiki/Survivorship_bias">survivorship bias</a>: although users may recognize certain types of posts that appear on the front page, there are many more which follow the same patterns but fail, which makes modeling a successful post very tricky.</p>
<p>I&rsquo;ve touched on analyzing Reddit post performance <a href="https://minimaxir.com/2017/06/reddit-deep-learning/">before</a>, but let&rsquo;s give it another look and see if we can drill down on why Reddit posts do and do not do well.</p>
<h2 id="submission-timing">Submission Timing</h2>
<p>As with many US-based websites, the majority of Reddit users are most active during work hours (9 AM — 5 PM Eastern time weekdays). Most subreddits have submission patterns which fit accordingly.</p>
<figure>

    <img loading="lazy" srcset="/2018/09/modeling-link-aggregators/reddit_subreddit_prop_hu_6063ab19aff16cb2.webp 320w,/2018/09/modeling-link-aggregators/reddit_subreddit_prop_hu_4354ae33b8600c6a.webp 768w,/2018/09/modeling-link-aggregators/reddit_subreddit_prop_hu_5818614336fda8df.webp 1024w,/2018/09/modeling-link-aggregators/reddit_subreddit_prop.png 1800w" src="reddit_subreddit_prop.png"/> 
</figure>

<p>But what&rsquo;s interesting are the subreddits which <em>deviate</em> from that standard. Gaming subreddits (<a href="https://www.reddit.com/r/DestinyTheGame">/r/DestinyTheGame</a>, <a href="https://www.reddit.com/r/Overwatch">/r/Overwatch</a>) have short activity after a Tuesday game update/patch, game <em>communication</em> subreddits (<a href="https://www.reddit.com/r/Fireteams">/r/Fireteams</a>, <a href="https://www.reddit.com/r/RocketLeagueExchange">/r/RocketLeagueExchange</a>) are more active <em>outside</em> of work hours as they assume you are playing the game at the time, and Not-Safe-For-Work subreddits (/r/dirtykikpals, /r/gonewild) are incidentally less active during work hours and more active late-night than other subreddits.</p>
<p>Whenever you make a submission to Reddit, the submission appears in the subreddit&rsquo;s <code>/new</code> queue of the most recent submissions, where hopefully kind souls will find your submission and upvote it if it&rsquo;s good.</p>
<figure>

    <img loading="lazy" srcset="/2018/09/modeling-link-aggregators/reddit_new_hu_6650be6d73851b91.webp 320w,/2018/09/modeling-link-aggregators/reddit_new.png 762w" src="reddit_new.png"/> 
</figure>

<p>However, if it falls off the first page of the <code>/new</code> queue, your submission might be as good as dead. As a result, there&rsquo;s an element of game theory to timing your submission if you want it to not become another 1-point submission. Is it better to submit during peak hours when more users may see the submission before it falls off of <code>/new</code>? Is it better to submit <em>before</em> peak usage since there will be less competition, then continue the momentum once it hits the front page?</p>
<p>Here&rsquo;s a look at the median post performance at each given time slot for top subreddits:</p>
<figure>

    <img loading="lazy" srcset="/2018/09/modeling-link-aggregators/reddit_subreddit_hr_doy_hu_cb9c5ba898252674.webp 320w,/2018/09/modeling-link-aggregators/reddit_subreddit_hr_doy_hu_8ba4a17a13989a31.webp 768w,/2018/09/modeling-link-aggregators/reddit_subreddit_hr_doy_hu_a08bfb9858ec4480.webp 1024w,/2018/09/modeling-link-aggregators/reddit_subreddit_hr_doy.png 1800w" src="reddit_subreddit_hr_doy.png"/> 
</figure>

<p>As the earlier distribution chart implied, the median score is around 1-2 for most subreddits, and that&rsquo;s consistent across all time slots. Some subreddits with higher medians like /r/me<em>irl do appear to have a _slight</em> benefit when posting before peak activity. When focusing on subreddits with high overall median scores, the difference is more explicit.</p>
<figure>

    <img loading="lazy" srcset="/2018/09/modeling-link-aggregators/reddit_subreddit_highmedian_hu_2730023d99e9e0d9.webp 320w,/2018/09/modeling-link-aggregators/reddit_subreddit_highmedian_hu_78be513d900d66b5.webp 768w,/2018/09/modeling-link-aggregators/reddit_subreddit_highmedian_hu_da4a41445f75e1.webp 1024w,/2018/09/modeling-link-aggregators/reddit_subreddit_highmedian.png 1800w" src="reddit_subreddit_highmedian.png"/> 
</figure>

<p>Subreddits like /r/PrequelMemes and /r/The<em>Donald _definitely</em> have better performance on average when made before peak activity! Posting before peak usage <em>does</em> appear to be a viable strategy, however for the majority of subreddits it doesn&rsquo;t make much of a difference.</p>
<h2 id="submission-titles">Submission Titles</h2>
<p>Each Reddit subreddit has their own vocabulary and topics of discussion. Let&rsquo;s break down text by subreddit by looking at the 75th percentile for score on posts containing a given two-word phrase:</p>
<figure>

    <img loading="lazy" srcset="/2018/09/modeling-link-aggregators/reddit_subreddit_topbigrams_hu_5d8f080824cf057d.webp 320w,/2018/09/modeling-link-aggregators/reddit_subreddit_topbigrams_hu_2870270c6078715e.webp 768w,/2018/09/modeling-link-aggregators/reddit_subreddit_topbigrams_hu_9edc52c78d8fe6ca.webp 1024w,/2018/09/modeling-link-aggregators/reddit_subreddit_topbigrams.png 1800w" src="reddit_subreddit_topbigrams.png"/> 
</figure>

<p>The one trend consistent across all subreddits is the effectiveness of first-person pronouns (<em>I/my</em>) and original content (<em>fan art</em>). Other than that, the vocabulary and sentiment for successful posts is very specific to the subreddit and culture is represents; no universal guaranteed-success memes.</p>
<h2 id="can-deep-learning-predict-post-performance">Can Deep Learning Predict Post Performance?</h2>
<p>Some might think &ldquo;oh hey, this is an arbitrary statistical problem, you can just build an AI to solve it!&rdquo; So, for the sake of argument, I did.</p>
<p>Instead of using Reddit data for building a deep learning model, we&rsquo;ll use data from <a href="https://news.ycombinator.com">Hacker News</a>, another link aggregator similar to Reddit with a strong focus on technology and startup entrepreneurship. The distribution of scores on posts, submission timings, upvoting, and front page ranking systems are all the same as on Reddit.</p>
<figure>

    <img loading="lazy" srcset="/2018/09/modeling-link-aggregators/hn_hu_ad0b8ce0803e73ea.webp 320w,/2018/09/modeling-link-aggregators/hn_hu_9592bce993e10dcd.webp 768w,/2018/09/modeling-link-aggregators/hn_hu_c329d6412551f993.webp 1024w,/2018/09/modeling-link-aggregators/hn.png 1520w" src="hn.png"/> 
</figure>

<p>The titles on Hacker News submissions are also shorter (80 characters max vs. Reddit&rsquo;s 300 character max) and in concise English (no memes/shitposts allowed), which should help the model learn the title syntax and identify high-impact keywords easier. Like Reddit, the score data is super-skewed with most HN submissions at 1-2 points, and typical model training will quickly converge but try to predict that <em>every</em> submission has a score of 1, which isn&rsquo;t helpful!</p>
<p>By constructing a model employing <em>many</em> deep learning tricks with <a href="https://keras.io">Keras</a>/<a href="https://www.tensorflow.org">TensorFlow</a> to prevent model cheating and training on <em>hundreds of thousands</em> of HN submissions (using post title, day-of-week, hour, and link domain like <code>github.com</code> as model features), the model does converge and finds some signal among the noise (training R<sup>2</sup> ~ 0.55 when trained for 50 epochs). However, it fails to offer any valuable predictions on new, unseen posts (test R<sup>2</sup> <em>&lt; 0.00</em>) because it falls into the same exact human biases regarding titles: it saw submissions with titles that did very well during training, but can&rsquo;t isolate the random chance why X and Y submissions are similar but X goes viral while Y does not.</p>
<figure>

    <img loading="lazy" srcset="/2018/09/modeling-link-aggregators/hn_test_hu_75e647e4de235ee0.webp 320w,/2018/09/modeling-link-aggregators/hn_test.png 485w" src="hn_test.png"/> 
</figure>

<p>I&rsquo;ve made the Keras/TensorFlow model training code available in <a href="https://www.kaggle.com/minimaxir/hacker-news-submission-score-predictor/notebook">this Kaggle Notebook</a> if you want to fork it and try to improve the model.</p>
<h2 id="other-potential-modeling-factors">Other Potential Modeling Factors</h2>
<p>The deep learning model above makes optimistic assumptions about the underlying data, including that each post behaves independently, and the included features are the sole features which determine the score. These assumptions are questionable.</p>
<p>The simple model forgoes the content of the submission itself, which is hard to retrieve for hundreds of thousands of data points. On Hacker News that&rsquo;s mostly OK since most submissions are links/articles which accurately correlate to the content, although occasionally there are idiosyncratic short titles which do the opposite. On Reddit, obviously looking at content is necessary for image/video-oriented subreddits, which is hard to gather and analyze at scale.</p>
<p>A very important concept of post performance is <em>momentum</em>. A post having a high score is a positive signal in itself, which begets more votes (a famous Reddit problem is brigading from /r/all which can cause submission scores to skyrocket). If the front page of a subreddit has a large number of high-performing posts, they might also suppress posts coming out of the <code>/new</code> queue because the score threshold is much higher. A simple model may not be able to capture these impacts; the model would need to incorporate the <em>state of the front page</em> at the time of posting.</p>
<p>Some also try to manipulate upvotes. Reddit became famous for adding the rule &ldquo;asking for upvotes is a violation of intergalactic law&rdquo; to their <a href="https://www.reddithelp.com/en/categories/rules-reporting/account-and-community-restrictions/what-constitutes-vote-cheating-or">Content Policy</a>, although some subreddits do it anyway <a href="https://www.reddit.com/r/TheoryOfReddit/comments/5qqrod/for_years_reddit_told_us_that_saying_upvote_this/">without consequence</a>. On Reddit, obvious spam posts can be downvoted to immediately counteract illicit upvotes. Hacker News has a <a href="https://news.ycombinator.com/newsfaq.html">similar don&rsquo;t-upvote rule</a>, although there aren&rsquo;t downvotes, just a flagging mechanism which quickly neutralizes spam/misleading posts. In general, there&rsquo;s no <em>legitimate</em> reason to highlight your own submission immediately after its posted (except for Reddit&rsquo;s AMAs). Fortunately, gaming the system is less impactful on Reddit and Hacker News due to their sheer size and countermeasures, but it&rsquo;s a good example of potential user behavior that makes modeling post performance difficult, and hopefully link aggregators of the future aren&rsquo;t susceptible to such shenanigans.</p>
<h2 id="do-we-really-to-predict-post-score">Do We Really to Predict Post Score?</h2>
<p>Let&rsquo;s say you are submitting original content to Reddit or your own tech project to Hacker News. More points means a higher ranking means more exposure for your link, right? Not exactly. As noted from Reddit/HN screenshots above, the scores of popular submissions are all over the place ranking-wise, having been affected by age penalties.</p>
<p>In practical terms, from my own purely anecdotal experience, submissions at a top ranking receive <em>substantially</em> more clickthroughs despite being spatially close on the page to others.</p>
<p><span><blockquote class="twitter-tweet" data-lang="en"><p lang="en" dir="ltr">&hellip;and now traffic at #3.<br><br>Placement is absurdly important for search engines/social media sites. Difference between #1 and #3 is dramatic. <a href="https://t.co/nGjWJBx6dU">pic.twitter.com/nGjWJBx6dU</a></p>— Max Woolf (@minimaxir) <a href="https://twitter.com/minimaxir/status/877219784907149316?ref_src=twsrc%5Etfw">June 20, 2017</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script></span></p>
<p>In <a href="https://twitter.com/minimaxir/status/877219784907149316">that case</a>, falling from #1 to #3 <em>immediately halved</em> the referral traffic coming from Hacker News.</p>
<p>Therefore, an ideal link aggregator predictive model to maximize clicks should try to predict the <em>rank</em> of a submission (max rank, average rank over <em>n</em> period, etc.), not necessarily the score it receives. You could theoretically create a model by making a snapshot of a Reddit subreddit/front page of Hacker News every minute or so which includes the post position at the time of the snapshot. As mentioned earlier, the snapshots can also be used as a model feature to identify whether the front page is active or stale. Unfortunately, snapshots can&rsquo;t be retrieved retroactively, and both storing, processing, and analyzing snapshots at scale is a difficult and <em>expensive</em> feat of data engineering.</p>
<p>Presumably Reddit&rsquo;s data scientists would be incorporating submission position as a part of their data analytics and modeling, but after inspecting what&rsquo;s sent to Reddit&rsquo;s servers when you perform an action like upvoting, I wasn&rsquo;t able to find a sent position value when upvoting from the feed: only the post score and post upvote percentage at the time of the action were sent.</p>
<figure>

    <img loading="lazy" srcset="/2018/09/modeling-link-aggregators/chrome_hu_4b758c7e3fe42881.webp 320w,/2018/09/modeling-link-aggregators/chrome_hu_29f25ed9207a6d8f.webp 768w,/2018/09/modeling-link-aggregators/chrome_hu_f6617992d5fb908c.webp 1024w,/2018/09/modeling-link-aggregators/chrome.png 1442w" src="chrome.png"/> 
</figure>

<p>In this example, I upvoted the <code>Fact are facts</code> submission at position #5: we&rsquo;d expect a value between <code>3</code> and <code>5</code> be sent with the post metadata within the analytics payload, but that&rsquo;s not the case.</p>
<p>Optimizing ranking instead of a tangible metric or classification accuracy is a relatively underdiscussed field of modern data science (besides <a href="https://en.wikipedia.org/wiki/Search_engine_optimization">SEO</a> for getting the top spot on a Google search), and it would be interesting to dive deeper into it for other applications.</p>
<h2 id="in-the-future">In the future</h2>
<p>The moral of this post is that you should not take it personally if a submission fails to hit the front page. It doesn&rsquo;t necessarily mean it&rsquo;s bad. Conversely, if a post does well, don’t assume that similar posts will do just as well. There&rsquo;s a lot of quality content that falls through the cracks due to dumb luck. Fortunately, both Reddit and Hacker News allow reposts, which helps alleviate this particular problem.</p>
<p>There&rsquo;s still a lot that can be done to more deterministically predict the behavior of these algorithmic feeds. There&rsquo;s also room to help make these link aggregators more <em>fair</em>. Unfortunately, there&rsquo;s even more undiscovered ways to game these algorithms, and we&rsquo;ll see how things play out.</p>
<hr>
<p><em>You can view the BigQuery queries used to get the Reddit and Hacker News data, plus the R and ggplot2 used to create the data visualizations, in <a href="http://minimaxir.com/notebooks/modeling-link-aggregators/">this R Notebook</a>. You can also view the images/code used for this post in <a href="https://github.com/minimaxir/modeling-link-aggregators">this GitHub repository</a></em>.</p>
<p><em>You are free to use the data visualizations from this article however you wish, but it would be greatly appreciated if proper attribution is given to this article and/or myself!</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Analyzing IMDb Data The Intended Way, with R and ggplot2</title>
      <link>https://minimaxir.com/2018/07/imdb-data-analysis/</link>
      <pubDate>Mon, 16 Jul 2018 09:45:00 -0700</pubDate>
      <guid>https://minimaxir.com/2018/07/imdb-data-analysis/</guid>
      <description>For IMDb&amp;rsquo;s big-but-not-big data, you have to play with the data smartly, and both R and ggplot2 have neat tricks to do just that.</description>
      <content:encoded><![CDATA[<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
      <iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube-nocookie.com/embed/P4_zSfoTM80?autoplay=0&amp;controls=1&amp;end=0&amp;loop=0&amp;mute=0&amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"></iframe>
    </div>

<p><a href="https://www.imdb.com">IMDb</a>, the Internet Movie Database, has been a popular source for data analysis and visualizations over the years. The combination of user ratings for movies and detailed movie metadata have always been fun to <a href="http://minimaxir.com/2016/01/movie-revenue-ratings/">play with</a>.</p>
<p>There are a number of tools to help get IMDb data, such as <a href="https://github.com/alberanid/imdbpy">IMDbPY</a>, which makes it easy to programmatically scrape IMDb by pretending it&rsquo;s a website user and extracting the relevant data from the page&rsquo;s HTML output. While it <em>works</em>, web scraping public data is a gray area in terms of legality; many large websites have a Terms of Service which forbids scraping, and can potentially send a DMCA take-down notice to websites redistributing scraped data.</p>
<p>IMDb has <a href="https://help.imdb.com/article/imdb/general-information/can-i-use-imdb-data-in-my-software/G5JTRESSHJBBHTGX">data licensing terms</a> which forbid scraping and require an attribution in the form of a <strong>Information courtesy of IMDb (<a href="http://www.imdb.com">http://www.imdb.com</a>). Used with permission.</strong> statement, and has also <a href="https://www.kaggle.com/tmdb/tmdb-movie-metadata/home">DMCAed a Kaggle IMDb dataset</a> to hone the point.</p>
<p>However, there is good news! IMDb publishes an <a href="https://www.imdb.com/interfaces/">official dataset</a> for casual data analysis! And it&rsquo;s now very accessible, just choose a dataset and download (now with no hoops to jump through), and the files are in the standard <a href="https://en.wikipedia.org/wiki/Tab-separated_values">TSV format</a>.</p>
<figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/datasets_hu_fb4ad2ef1d7c9e7f.webp 320w,/2018/07/imdb-data-analysis/datasets_hu_a5155a40c73aa984.webp 768w,/2018/07/imdb-data-analysis/datasets.png 926w" src="datasets.png"/> 
</figure>

<p>The uncompressed files are pretty large; not &ldquo;big data&rdquo; large (it fits into computer memory), but Excel will explode if you try to open them in it. You have to play with the data <em>smartly</em>, and both <a href="https://www.r-project.org">R</a> and <a href="https://ggplot2.tidyverse.org/reference/index.html">ggplot2</a> have neat tricks to do just that.</p>
<h2 id="first-steps">First Steps</h2>
<p>R is a popular programming language for statistical analysis. One of the most popular series of external packages is the <code>tidyverse</code> package, which automatically imports the <code>ggplot2</code> data visualization library and other useful packages which we&rsquo;ll get to one-by-one. We&rsquo;ll also use <code>scales</code> which we&rsquo;ll use later for prettier number formatting. First we&rsquo;ll load these packages:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="nf">library</span><span class="p">(</span><span class="n">tidyverse</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nf">library</span><span class="p">(</span><span class="n">scales</span><span class="p">)</span>
</span></span></code></pre></div><p>And now we can load a TSV downloaded from IMDb using the <code>read_tsv</code> function from <code>readr</code> (a tidyverse package), which does what the name implies, at a much faster speed than base R (+ a couple other parameters to handle data encoding). Let&rsquo;s start with the <code>ratings</code> file:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_ratings</span> <span class="o">&lt;-</span> <span class="nf">read_tsv</span><span class="p">(</span><span class="s">&#39;title.ratings.tsv&#39;</span><span class="p">,</span> <span class="n">na</span> <span class="o">=</span> <span class="s">&#34;\\N&#34;</span><span class="p">,</span> <span class="n">quote</span> <span class="o">=</span> <span class="s">&#39;&#39;</span><span class="p">)</span></span></span></code></pre></div>
<p>We can preview what&rsquo;s in the loaded data using <code>dplyr</code> (a tidyverse package), which is what we&rsquo;ll be using to manipulate data for this analysis. dplyr allows you to pipe commands, making it easy to create a sequence of manipulation commands. For now, we&rsquo;ll use <code>head()</code>, which displays the top few rows of the data frame.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_ratings</span> <span class="o">%&gt;%</span> <span class="nf">head</span><span class="p">()</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/ratings_hu_5c1fcf56a5289876.webp 320w,/2018/07/imdb-data-analysis/ratings_hu_cf3fece2f9c850ca.webp 768w,/2018/07/imdb-data-analysis/ratings.png 930w" src="ratings.png"/> 
</figure>

<p>Each of the <strong>873k rows</strong> corresponds to a single movie, an ID for the movie, its average rating (from 1 to 10), and the number of votes which contribute to that average. Since we have two numeric variables, why not test out ggplot2 by creating a scatterplot mapping them? ggplot2 takes in a data frame and names of columns as aesthetics, then you specify what type of shape to plot (a &ldquo;geom&rdquo;). Passing the plot to <code>ggsave</code> saves it as a standalone, high-quality data visualization.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">plot</span> <span class="o">&lt;-</span> <span class="nf">ggplot</span><span class="p">(</span><span class="n">df_ratings</span><span class="p">,</span> <span class="nf">aes</span><span class="p">(</span><span class="n">x</span> <span class="o">=</span> <span class="n">numVotes</span><span class="p">,</span> <span class="n">y</span> <span class="o">=</span> <span class="n">averageRating</span><span class="p">))</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">geom_point</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">ggsave</span><span class="p">(</span><span class="s">&#34;imdb-0.png&#34;</span><span class="p">,</span> <span class="n">plot</span><span class="p">,</span> <span class="n">width</span> <span class="o">=</span> <span class="m">4</span><span class="p">,</span> <span class="n">height</span> <span class="o">=</span> <span class="m">3</span><span class="p">)</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/imdb-0_hu_6866c079d670893c.webp 320w,/2018/07/imdb-data-analysis/imdb-0_hu_dddd194229265d79.webp 768w,/2018/07/imdb-data-analysis/imdb-0_hu_1d852e43e8a54dea.webp 1024w,/2018/07/imdb-data-analysis/imdb-0.png 1200w" src="imdb-0.png"/> 
</figure>

<p>Here is nearly <em>1 million</em> points on a single chart; definitely don&rsquo;t try to do that in Excel! However, it&rsquo;s not a <em>useful</em> chart since all the points are opaque and we&rsquo;re not sure what the spatial density of points is. One approach to fix this issue is to create a heat map of points, which ggplot can do natively with <code>geom_bin2d</code>. We can color the heat map with the <a href="https://cran.r-project.org/web/packages/viridis/vignettes/intro-to-viridis.html">viridis</a> colorblind-friendly palettes <a href="https://ggplot2.tidyverse.org/reference/scale_viridis.html">just introduced</a> into ggplot2. We should also tweak the axes; the x-axis should be scaled logarithmically with <code>scale_x_log10</code> since there are many movies with high numbers of votes and we can format those numbers with the <code>comma</code> function from the <code>scales</code> package (we can format the scale with <code>comma</code> too). For the y-axis, we can add explicit number breaks for each rating; R can do this neatly by setting the breaks to <code>1:10</code>. Putting it all together:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">plot</span> <span class="o">&lt;-</span> <span class="nf">ggplot</span><span class="p">(</span><span class="n">df_ratings</span><span class="p">,</span> <span class="nf">aes</span><span class="p">(</span><span class="n">x</span> <span class="o">=</span> <span class="n">numVotes</span><span class="p">,</span> <span class="n">y</span> <span class="o">=</span> <span class="n">averageRating</span><span class="p">))</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">geom_bin2d</span><span class="p">()</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">scale_x_log10</span><span class="p">(</span><span class="n">labels</span> <span class="o">=</span> <span class="n">comma</span><span class="p">)</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">scale_y_continuous</span><span class="p">(</span><span class="n">breaks</span> <span class="o">=</span> <span class="m">1</span><span class="o">:</span><span class="m">10</span><span class="p">)</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">scale_fill_viridis_c</span><span class="p">(</span><span class="n">labels</span> <span class="o">=</span> <span class="n">comma</span><span class="p">)</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/imdb-1_hu_afa4c2e2f89a47f2.webp 320w,/2018/07/imdb-data-analysis/imdb-1_hu_fb49622c671e7e.webp 768w,/2018/07/imdb-data-analysis/imdb-1_hu_fe5886baf1a1a113.webp 1024w,/2018/07/imdb-data-analysis/imdb-1.png 1200w" src="imdb-1.png"/> 
</figure>

<p>Not bad, although it unfortunately confirms that IMDb follows a <a href="https://tvtropes.org/pmwiki/pmwiki.php/Main/FourPointScale">Four Point Scale</a> where average ratings tend to fall between 6 — 9.</p>
<h2 id="mapping-movies-to-ratings">Mapping Movies to Ratings</h2>
<p>You may be asking &ldquo;which ratings correspond to which movies?&rdquo; That&rsquo;s what the <code>tconst</code> field is for. But first, let&rsquo;s load the title data from <code>title.basics.tsv</code> into <code>df_basics</code> and take a look as before.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_basics</span> <span class="o">&lt;-</span> <span class="nf">read_tsv</span><span class="p">(</span><span class="s">&#39;title.basics.tsv&#39;</span><span class="p">,</span> <span class="n">na</span> <span class="o">=</span> <span class="s">&#34;\\N&#34;</span><span class="p">,</span> <span class="n">quote</span> <span class="o">=</span> <span class="s">&#39;&#39;</span><span class="p">)</span>
</span></span></code></pre></div><p><figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/basics1_hu_fdcb6a5f4e7311e5.webp 320w,/2018/07/imdb-data-analysis/basics1_hu_e15b78e5bbe944b8.webp 768w,/2018/07/imdb-data-analysis/basics1_hu_2e217e73acfcd9ff.webp 1024w,/2018/07/imdb-data-analysis/basics1.png 1350w" src="basics1.png"/> 
</figure>

<figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/basics2_hu_a64ae979748aa9ab.webp 320w,/2018/07/imdb-data-analysis/basics2_hu_a83799eaf31e4743.webp 768w,/2018/07/imdb-data-analysis/basics2_hu_21a8fb679f3ec4e9.webp 1024w,/2018/07/imdb-data-analysis/basics2.png 1374w" src="basics2.png"/> 
</figure>
</p>
<p>We have some neat movie metadata. Notably, this table has a <code>tconst</code> field as well. Therefore, we can <em>join</em> the two tables together, adding the movie information to the corresponding row in the rating table (in this case, a left join is more appropriate than an inner/full join)</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_ratings</span> <span class="o">&lt;-</span> <span class="n">df_ratings</span> <span class="o">%&gt;%</span> <span class="nf">left_join</span><span class="p">(</span><span class="n">df_basics</span><span class="p">)</span>
</span></span></code></pre></div><p>Runtime minutes sounds interesting. Could there be a relationship between the length of a movie and its average rating on IMDb? Let&rsquo;s make a heat map plot again, but with a few tweaks. With the new metadata, we can <code>filter</code> the table to remove bad points; let&rsquo;s keep movies only (as IMDb data also contains <em>television show data</em>), with a runtime &lt; 3 hours, and which have received atleast 10 votes by users to remove extraneous movies). X-axis should be tweaked to display the minutes-values in hours. The fill viridis palette can be changed to another one in the family (I personally like <code>inferno</code>).</p>
<p>More importantly, let&rsquo;s discuss plot theming. If you want a minimalistic theme, add a <code>theme_minimal</code> to the plot, and you can pass a <code>base_family</code> to change the default font on the plot and a <code>base_size</code> to change the font size. The <code>labs</code> function lets you add labels to the plot (which you should <em>always</em> do); you have your <code>title</code>, <code>x</code>, and <code>y</code> parameters, but you can also add a <code>subtitle</code>, a <code>caption</code> for attribution, and a <code>color</code>/<code>fill</code> to name the scale. Putting it all together:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">plot</span> <span class="o">&lt;-</span> <span class="nf">ggplot</span><span class="p">(</span><span class="n">df_ratings</span> <span class="o">%&gt;%</span> <span class="nf">filter</span><span class="p">(</span><span class="n">runtimeMinutes</span> <span class="o">&lt;</span> <span class="m">180</span><span class="p">,</span> <span class="n">titleType</span> <span class="o">==</span> <span class="s">&#34;movie&#34;</span><span class="p">,</span> <span class="n">numVotes</span> <span class="o">&gt;=</span> <span class="m">10</span><span class="p">),</span> <span class="nf">aes</span><span class="p">(</span><span class="n">x</span> <span class="o">=</span> <span class="n">runtimeMinutes</span><span class="p">,</span> <span class="n">y</span> <span class="o">=</span> <span class="n">averageRating</span><span class="p">))</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">geom_bin2d</span><span class="p">()</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">scale_x_continuous</span><span class="p">(</span><span class="n">breaks</span> <span class="o">=</span> <span class="nf">seq</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">180</span><span class="p">,</span> <span class="m">60</span><span class="p">),</span> <span class="n">labels</span> <span class="o">=</span> <span class="m">0</span><span class="o">:</span><span class="m">3</span><span class="p">)</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">scale_y_continuous</span><span class="p">(</span><span class="n">breaks</span> <span class="o">=</span> <span class="m">0</span><span class="o">:</span><span class="m">10</span><span class="p">)</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">scale_fill_viridis_c</span><span class="p">(</span><span class="n">option</span> <span class="o">=</span> <span class="s">&#34;inferno&#34;</span><span class="p">,</span> <span class="n">labels</span> <span class="o">=</span> <span class="n">comma</span><span class="p">)</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">theme_minimal</span><span class="p">(</span><span class="n">base_family</span> <span class="o">=</span> <span class="s">&#34;Source Sans Pro&#34;</span><span class="p">,</span> <span class="n">base_size</span> <span class="o">=</span> <span class="m">8</span><span class="p">)</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">labs</span><span class="p">(</span><span class="n">title</span> <span class="o">=</span> <span class="s">&#34;Relationship between Movie Runtime and Average Mobie Rating&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">               <span class="n">subtitle</span> <span class="o">=</span> <span class="s">&#34;Data from IMDb retrieved July 4th, 2018&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">               <span class="n">x</span> <span class="o">=</span> <span class="s">&#34;Runtime (Hours)&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">               <span class="n">y</span> <span class="o">=</span> <span class="s">&#34;Average User Rating&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">               <span class="n">caption</span> <span class="o">=</span> <span class="s">&#34;Max Woolf — minimaxir.com&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">               <span class="n">fill</span> <span class="o">=</span> <span class="s">&#34;# Movies&#34;</span><span class="p">)</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/imdb-2b_hu_37c6091878dca7a3.webp 320w,/2018/07/imdb-data-analysis/imdb-2b_hu_42f5a5f9d2e7967e.webp 768w,/2018/07/imdb-data-analysis/imdb-2b_hu_b4f485eff14f2484.webp 1024w,/2018/07/imdb-data-analysis/imdb-2b.png 1200w" src="imdb-2b.png"/> 
</figure>

<p>Now that&rsquo;s pretty nice-looking for only a few lines of code! Albeit unhelpful, as there doesn&rsquo;t appear to be a correlation.</p>
<p><em>(Note: for the rest of this post, the theming/labels code will be omitted for convenience)</em></p>
<p>How about movie ratings vs. the year the movie was made? It&rsquo;s a similar plot code-wise to the one above (one perk about <code>ggplot2</code> is that there&rsquo;s no shame in reusing chart code!), but we can add a <code>geom_smooth</code>, which adds a nonparametric trendline with confidence bands for the trend; since we have a large amount of data, the bands are very tight. We can also fix the problem of &ldquo;empty&rdquo; bins by setting the color fill scale to logarithmic scaling. And since we&rsquo;re adding a black trendline, let&rsquo;s change the viridis palette to <code>plasma</code> for better contrast.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">plot</span> <span class="o">&lt;-</span> <span class="nf">ggplot</span><span class="p">(</span><span class="n">df_ratings</span> <span class="o">%&gt;%</span> <span class="nf">filter</span><span class="p">(</span><span class="n">titleType</span> <span class="o">==</span> <span class="s">&#34;movie&#34;</span><span class="p">,</span> <span class="n">numVotes</span> <span class="o">&gt;=</span> <span class="m">10</span><span class="p">),</span> <span class="nf">aes</span><span class="p">(</span><span class="n">x</span> <span class="o">=</span> <span class="n">startYear</span><span class="p">,</span> <span class="n">y</span> <span class="o">=</span> <span class="n">averageRating</span><span class="p">))</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">geom_bin2d</span><span class="p">()</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">geom_smooth</span><span class="p">(</span><span class="n">color</span><span class="o">=</span><span class="s">&#34;black&#34;</span><span class="p">)</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">scale_x_continuous</span><span class="p">()</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">scale_y_continuous</span><span class="p">(</span><span class="n">breaks</span> <span class="o">=</span> <span class="m">1</span><span class="o">:</span><span class="m">10</span><span class="p">)</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">scale_fill_viridis_c</span><span class="p">(</span><span class="n">option</span> <span class="o">=</span> <span class="s">&#34;plasma&#34;</span><span class="p">,</span> <span class="n">labels</span> <span class="o">=</span> <span class="n">comma</span><span class="p">,</span> <span class="n">trans</span> <span class="o">=</span> <span class="s">&#39;log10&#39;</span><span class="p">)</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/imdb-4_hu_fdf90cbdd2dd2c7e.webp 320w,/2018/07/imdb-data-analysis/imdb-4_hu_1c45abe215427c09.webp 768w,/2018/07/imdb-data-analysis/imdb-4_hu_62d0feb034e8b054.webp 1024w,/2018/07/imdb-data-analysis/imdb-4.png 1200w" src="imdb-4.png"/> 
</figure>

<p>Unfortunately, this trend hasn&rsquo;t changed much either, although the presence of average ratings outside the Four Point Scale has increased over time.</p>
<h2 id="mapping-lead-actors-to-movies">Mapping Lead Actors to Movies</h2>
<p>Now that we have a handle on working with the IMDb data, let&rsquo;s try playing with the larger datasets. Since they take up a lot of computer memory, we only want to persist data we actually might use. After looking at the schema provided with the official datasets, the only really useful metadata about the actors is their birth year, so let&rsquo;s load that, but only keep both actors/actresses (using the fast <code>str_detect</code> function from <code>stringr</code>, another tidyverse package) and the relevant fields.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_actors</span> <span class="o">&lt;-</span> <span class="nf">read_tsv</span><span class="p">(</span><span class="s">&#39;name.basics.tsv&#39;</span><span class="p">,</span> <span class="n">na</span> <span class="o">=</span> <span class="s">&#34;\\N&#34;</span><span class="p">,</span> <span class="n">quote</span> <span class="o">=</span> <span class="s">&#39;&#39;</span><span class="p">)</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">                <span class="nf">filter</span><span class="p">(</span><span class="nf">str_detect</span><span class="p">(</span><span class="n">primaryProfession</span><span class="p">,</span> <span class="s">&#34;actor|actress&#34;</span><span class="p">))</span>  <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">                <span class="nf">select</span><span class="p">(</span><span class="n">nconst</span><span class="p">,</span> <span class="n">primaryName</span><span class="p">,</span> <span class="n">birthYear</span><span class="p">)</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/actor_hu_f86030d94734f51e.webp 320w,/2018/07/imdb-data-analysis/actor_hu_58f7a4e4de86c210.webp 768w,/2018/07/imdb-data-analysis/actor.png 936w" src="actor.png"/> 
</figure>

<p>The principals dataset, the large 1.28GB TSV, is the most interesting. It&rsquo;s an unnested list of the credited persons in each movie, with an <code>ordering</code> indicating their rank (where <code>1</code> means first, <code>2</code> means second, etc.).</p>
<figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/principals_hu_e149270e85e6bbfe.webp 320w,/2018/07/imdb-data-analysis/principals_hu_d39d7c6fcd18929.webp 768w,/2018/07/imdb-data-analysis/principals_hu_56b42bde8cdb5364.webp 1024w,/2018/07/imdb-data-analysis/principals.png 1074w" src="principals.png"/> 
</figure>

<p>For this analysis, let&rsquo;s only look at the <strong>lead actors/actresses</strong>; specifically, for each movie (identified by the <code>tconst</code> value), filter the dataset to where the <code>ordering</code> value is the lowest (in this case, the person at rank <code>1</code> may not necessarily be an actor/actress).</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_principals</span> <span class="o">&lt;-</span> <span class="nf">read_tsv</span><span class="p">(</span><span class="s">&#39;title.principals.tsv&#39;</span><span class="p">,</span> <span class="n">na</span> <span class="o">=</span> <span class="s">&#34;\\N&#34;</span><span class="p">,</span> <span class="n">quote</span> <span class="o">=</span> <span class="s">&#39;&#39;</span><span class="p">)</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">  <span class="nf">filter</span><span class="p">(</span><span class="nf">str_detect</span><span class="p">(</span><span class="n">category</span><span class="p">,</span> <span class="s">&#34;actor|actress&#34;</span><span class="p">))</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">  <span class="nf">select</span><span class="p">(</span><span class="n">tconst</span><span class="p">,</span> <span class="n">ordering</span><span class="p">,</span> <span class="n">nconst</span><span class="p">,</span> <span class="n">category</span><span class="p">)</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">  <span class="nf">group_by</span><span class="p">(</span><span class="n">tconst</span><span class="p">)</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">  <span class="nf">filter</span><span class="p">(</span><span class="n">ordering</span> <span class="o">==</span> <span class="nf">min</span><span class="p">(</span><span class="n">ordering</span><span class="p">))</span>
</span></span></code></pre></div><p>Both datasets have a <code>nconst</code> field, so let&rsquo;s join them together. And then join <em>that</em> to the ratings table earlier via <code>tconst</code>.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_principals</span> <span class="o">&lt;-</span> <span class="n">df_principals</span> <span class="o">%&gt;%</span> <span class="nf">left_join</span><span class="p">(</span><span class="n">df_actors</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">df_ratings</span> <span class="o">&lt;-</span> <span class="n">df_ratings</span> <span class="o">%&gt;%</span> <span class="nf">left_join</span><span class="p">(</span><span class="n">df_principals</span><span class="p">)</span>
</span></span></code></pre></div><p>Now we have a fully denormalized dataset in <code>df_ratings</code>. Since we now have the movie release year and the birth year of the lead actor, we can now infer <em>the age of the lead actor at the movie release</em>. With that goal, filter out the data on the criteria we&rsquo;ve used for earlier data visualizations, plus only keeping rows which have an actor&rsquo;s birth year.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_ratings_movies</span> <span class="o">&lt;-</span> <span class="n">df_ratings</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">                        <span class="nf">filter</span><span class="p">(</span><span class="n">titleType</span> <span class="o">==</span> <span class="s">&#34;movie&#34;</span><span class="p">,</span> <span class="o">!</span><span class="nf">is.na</span><span class="p">(</span><span class="n">birthYear</span><span class="p">),</span> <span class="n">numVotes</span> <span class="o">&gt;=</span> <span class="m">10</span><span class="p">)</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">                        <span class="nf">mutate</span><span class="p">(</span><span class="n">age_lead</span> <span class="o">=</span> <span class="n">startYear</span> <span class="o">-</span> <span class="n">birthYear</span><span class="p">)</span>
</span></span></code></pre></div><p><figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/denorm1_hu_654cad39747efe47.webp 320w,/2018/07/imdb-data-analysis/denorm1_hu_eed6e992d7e214e3.webp 768w,/2018/07/imdb-data-analysis/denorm1_hu_dbde12b6453e4f09.webp 1024w,/2018/07/imdb-data-analysis/denorm1.png 1604w" src="denorm1.png"/> 
</figure>

<figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/denorm2_hu_3aef3d94cde50e2c.webp 320w,/2018/07/imdb-data-analysis/denorm2.png 531w" src="denorm2.png"/> 
</figure>
</p>
<h2 id="plotting-ages">Plotting Ages</h2>
<p>Age discrimination in movie casting has been a recurring issue in Hollywood; in fact, in 2017 <a href="https://www.hollywoodreporter.com/thr-esq/judge-pauses-enforcement-imdb-age-censorship-law-978797">a law was signed</a> to force IMDb to remove an actor&rsquo;s age upon request, which in February 2018 was <a href="https://www.hollywoodreporter.com/thr-esq/californias-imdb-age-censorship-law-declared-unconstitutional-1086540">ruled to be unconstitutional</a>.</p>
<p>Have the ages of movie leads changed over time? For this example, we&rsquo;ll use a <a href="https://ggplot2.tidyverse.org/reference/geom_ribbon.html">ribbon plot</a> to plot the ranges of ages of movie leads. A simple way to do that is, for each year, calculate the 25th <a href="https://en.wikipedia.org/wiki/Percentile">percentile</a> of the ages, the 50th percentile (i.e. the median), and the 75th percentile, where the 25th and 75th percentiles are the ribbon bounds and the line represents the median.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_actor_ages</span> <span class="o">&lt;-</span> <span class="n">df_ratings_movies</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">                  <span class="nf">group_by</span><span class="p">(</span><span class="n">startYear</span><span class="p">)</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">                  <span class="nf">summarize</span><span class="p">(</span><span class="n">low_age</span> <span class="o">=</span> <span class="nf">quantile</span><span class="p">(</span><span class="n">age_lead</span><span class="p">,</span> <span class="m">0.25</span><span class="p">,</span> <span class="n">na.rm</span><span class="o">=</span><span class="bp">T</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">                            <span class="n">med_age</span> <span class="o">=</span> <span class="nf">quantile</span><span class="p">(</span><span class="n">age_lead</span><span class="p">,</span> <span class="m">0.50</span><span class="p">,</span> <span class="n">na.rm</span><span class="o">=</span><span class="bp">T</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">                            <span class="n">high_age</span> <span class="o">=</span> <span class="nf">quantile</span><span class="p">(</span><span class="n">age_lead</span><span class="p">,</span> <span class="m">0.75</span><span class="p">,</span> <span class="n">na.rm</span><span class="o">=</span><span class="bp">T</span><span class="p">))</span>
</span></span></code></pre></div><p>Plotting it with ggplot2 is surprisingly simple, although you need to use different y aesthetics for the ribbon and the overlapping line.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">plot</span> <span class="o">&lt;-</span> <span class="nf">ggplot</span><span class="p">(</span><span class="n">df_actor_ages</span> <span class="o">%&gt;%</span> <span class="nf">filter</span><span class="p">(</span><span class="n">startYear</span> <span class="o">&gt;=</span> <span class="m">1920</span><span class="p">)</span> <span class="p">,</span> <span class="nf">aes</span><span class="p">(</span><span class="n">x</span> <span class="o">=</span> <span class="n">startYear</span><span class="p">))</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">geom_ribbon</span><span class="p">(</span><span class="nf">aes</span><span class="p">(</span><span class="n">ymin</span> <span class="o">=</span> <span class="n">low_age</span><span class="p">,</span> <span class="n">ymax</span> <span class="o">=</span> <span class="n">high_age</span><span class="p">),</span> <span class="n">alpha</span> <span class="o">=</span> <span class="m">0.2</span><span class="p">)</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">geom_line</span><span class="p">(</span><span class="nf">aes</span><span class="p">(</span><span class="n">y</span> <span class="o">=</span> <span class="n">med_age</span><span class="p">))</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/imdb-8_hu_1f082993b0bfcbd5.webp 320w,/2018/07/imdb-data-analysis/imdb-8_hu_5434c1e3ce1485b4.webp 768w,/2018/07/imdb-data-analysis/imdb-8_hu_c6707a589573484a.webp 1024w,/2018/07/imdb-data-analysis/imdb-8.png 1200w" src="imdb-8.png"/> 
</figure>

<p>Turns out that in the 2000&rsquo;s, the median age of lead actors started to <em>increase</em>? Both the upper and lower bounds increased too. That doesn&rsquo;t coalesce with the age discrimination complaints.</p>
<p>Another aspect of these complaints is gender, as female actresses tend to be younger than male actors. Thanks to the magic of ggplot2 and dplyr, separating actors/actresses is relatively simple: add gender (encoded in <code>category</code>) as a grouping variable, add it as a color/fill aesthetic in ggplot, and set colors appropriately (I recommend the <a href="http://colorbrewer2.org/">ColorBrewer</a> qualitative palettes for categorical variables).</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_actor_ages_lead</span> <span class="o">&lt;-</span> <span class="n">df_ratings_movies</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">                  <span class="nf">group_by</span><span class="p">(</span><span class="n">startYear</span><span class="p">,</span> <span class="n">category</span><span class="p">)</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">                  <span class="nf">summarize</span><span class="p">(</span><span class="n">low_age</span> <span class="o">=</span> <span class="nf">quantile</span><span class="p">(</span><span class="n">age_lead</span><span class="p">,</span> <span class="m">0.25</span><span class="p">,</span> <span class="n">na.rm</span> <span class="o">=</span> <span class="bp">T</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">                            <span class="n">med_age</span> <span class="o">=</span> <span class="nf">quantile</span><span class="p">(</span><span class="n">age_lead</span><span class="p">,</span> <span class="m">0.50</span><span class="p">,</span> <span class="n">na.rm</span> <span class="o">=</span> <span class="bp">T</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">                            <span class="n">high_age</span> <span class="o">=</span> <span class="nf">quantile</span><span class="p">(</span><span class="n">age_lead</span><span class="p">,</span> <span class="m">0.75</span><span class="p">,</span> <span class="n">na.rm</span> <span class="o">=</span> <span class="bp">T</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">plot</span> <span class="o">&lt;-</span> <span class="nf">ggplot</span><span class="p">(</span><span class="n">df_actor_ages_lead</span> <span class="o">%&gt;%</span> <span class="nf">filter</span><span class="p">(</span><span class="n">startYear</span> <span class="o">&gt;=</span> <span class="m">1920</span><span class="p">),</span> <span class="nf">aes</span><span class="p">(</span><span class="n">x</span> <span class="o">=</span> <span class="n">startYear</span><span class="p">,</span> <span class="n">fill</span> <span class="o">=</span> <span class="n">category</span><span class="p">,</span> <span class="n">color</span> <span class="o">=</span> <span class="n">category</span><span class="p">))</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">geom_ribbon</span><span class="p">(</span><span class="nf">aes</span><span class="p">(</span><span class="n">ymin</span> <span class="o">=</span> <span class="n">low_age</span><span class="p">,</span> <span class="n">ymax</span> <span class="o">=</span> <span class="n">high_age</span><span class="p">),</span> <span class="n">alpha</span> <span class="o">=</span> <span class="m">0.2</span><span class="p">)</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">geom_line</span><span class="p">(</span><span class="nf">aes</span><span class="p">(</span><span class="n">y</span> <span class="o">=</span> <span class="n">med_age</span><span class="p">))</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">scale_fill_brewer</span><span class="p">(</span><span class="n">palette</span> <span class="o">=</span> <span class="s">&#34;Set1&#34;</span><span class="p">)</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">          <span class="nf">scale_color_brewer</span><span class="p">(</span><span class="n">palette</span> <span class="o">=</span> <span class="s">&#34;Set1&#34;</span><span class="p">)</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/imdb-9_hu_57562b2f234249be.webp 320w,/2018/07/imdb-data-analysis/imdb-9_hu_7da40c01dd2abee4.webp 768w,/2018/07/imdb-data-analysis/imdb-9_hu_a30111e8cbade2ed.webp 1024w,/2018/07/imdb-data-analysis/imdb-9.png 1200w" src="imdb-9.png"/> 
</figure>

<p>There&rsquo;s about a 10-year gap between the ages of male and female leads, and the gap doesn&rsquo;t change overtime. But both start to rise at the same time.</p>
<p>One possible explanation for this behavior is actor reuse: if Hollywood keeps casting the same actor/actresses, by construction the ages of the leads will start to steadily increase. Let&rsquo;s verify that: with our list of movies and their lead actors, for each lead actor, order all their movies by release year, and add a ranking for the #th time that actor has been a lead actor. This is possible through the use of <code>row_number</code> in dplyr, and <a href="https://cran.r-project.org/web/packages/dplyr/vignettes/window-functions.html">window functions</a> like <code>row_number</code> are data science&rsquo;s most useful secret.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_ratings_movies_nth</span> <span class="o">&lt;-</span> <span class="n">df_ratings_movies</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">                      <span class="nf">group_by</span><span class="p">(</span><span class="n">nconst</span><span class="p">)</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">                      <span class="nf">arrange</span><span class="p">(</span><span class="n">startYear</span><span class="p">)</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">                      <span class="nf">mutate</span><span class="p">(</span><span class="n">nth_lead</span> <span class="o">=</span> <span class="nf">row_number</span><span class="p">())</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/row_number_hu_1e44bdb2621fb9cb.webp 320w,/2018/07/imdb-data-analysis/row_number_hu_ca408294ce31483a.webp 768w,/2018/07/imdb-data-analysis/row_number_hu_ed006c80eb52873e.webp 1024w,/2018/07/imdb-data-analysis/row_number.png 1532w" src="row_number.png"/> 
</figure>

<p>One more ribbon plot later (w/ same code as above + custom y-axis breaks):</p>
<figure>

    <img loading="lazy" srcset="/2018/07/imdb-data-analysis/imdb-12_hu_32ee97febb68e3.webp 320w,/2018/07/imdb-data-analysis/imdb-12_hu_69e7d60d89429d8f.webp 768w,/2018/07/imdb-data-analysis/imdb-12_hu_c9df788e280bb63b.webp 1024w,/2018/07/imdb-data-analysis/imdb-12.png 1200w" src="imdb-12.png"/> 
</figure>

<p>Huh. The median and upper-bound #th time has <em>dropped</em> over time? Hollywood has been promoting more newcomers as leads? That&rsquo;s not what I expected!</p>
<p>More work definitely needs to be done in this area. In the meantime, the official IMDb datasets are a lot more robust than I thought they would be! And I only used a fraction of the datasets; the rest tie into TV shows, which are a bit messier. Hopefully you&rsquo;ve seen a good taste of the power of R and ggplot2 for playing with big-but-not-big data!</p>
<hr>
<p><em>You can view the R and ggplot used to create the data visualizations in <a href="http://minimaxir.com/notebooks/imdb-data-analysis/">this R Notebook</a>, which includes many visualizations not used in this post. You can also view the images/code used for this post in <a href="https://github.com/minimaxir/imdb-data-analysis">this GitHub repository</a></em>.</p>
<p><em>You are free to use the data visualizations from this article however you wish, but it would be greatly appreciated if proper attribution is given to this article and/or myself!</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Visualizing One Million NCAA Basketball Shots</title>
      <link>https://minimaxir.com/2018/03/basketball-shots/</link>
      <pubDate>Mon, 19 Mar 2018 09:20:00 -0700</pubDate>
      <guid>https://minimaxir.com/2018/03/basketball-shots/</guid>
      <description>Although visualizing basketball shots has been done before, this time we have access to an order of magnitude more public data to do some really cool stuff.</description>
      <content:encoded><![CDATA[<p>So <a href="https://www.ncaa.com/march-madness">March Madness</a> is happing right now. In celebration, <a href="https://www.google.com">Google</a> uploaded <a href="https://console.cloud.google.com/launcher/details/ncaa-bb-public/ncaa-basketball">massive basketball datasets</a> from the <a href="https://www.ncaa.com">NCAA</a> and <a href="https://www.sportradar.com/">Sportradar</a> to <a href="https://cloud.google.com/bigquery/">BigQuery</a> for anyone to query and experiment. After learning that the <a href="https://www.reddit.com/r/bigquery/comments/82nz17/dataset_statistics_for_ncaa_mens_and_womens/">dataset had location data</a> on where basketball shots were made on the court, I played with it and a couple hours later, I created a decent heat map data visualization. The next day, I <a href="https://www.reddit.com/r/dataisbeautiful/comments/837qnu/heat_map_of_1058383_basketball_shots_from_ncaa/">posted it</a> to Reddit&rsquo;s <a href="https://www.reddit.com/r/dataisbeautiful">/r/dataisbeautiful subreddit</a> where it earned about <strong>40,000 upvotes</strong>. (!?)</p>
<p>Let&rsquo;s dig a little deeper. Although visualizing basketball shots has been <a href="http://www.slate.com/blogs/browbeat/2012/03/06/mapping_the_nba_how_geography_can_teach_players_where_to_shoot.html">done</a> <a href="http://toddwschneider.com/posts/ballr-interactive-nba-shot-charts-with-r-and-shiny/">before</a>, this time we have access to an order of magnitude more public data to do some really cool stuff.</p>
<h2 id="full-court">Full Court</h2>
<p>The Sportradar play-by-play table on BigQuery <code>mbb_pbp_sr</code> has more than 1 million NCAA men&rsquo;s basketball shots since the 2013-2014 season, with more being added now during March Madness. Here&rsquo;s a heat map of the locations where those shots were made on the full basketball court:</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_count_attempts_unlog_hu_35ce830f74de77b5.webp 320w,/2018/03/basketball-shots/ncaa_count_attempts_unlog_hu_ff7511dcccb6bf50.webp 768w,/2018/03/basketball-shots/ncaa_count_attempts_unlog_hu_c03f9beaec2e4059.webp 1024w,/2018/03/basketball-shots/ncaa_count_attempts_unlog.png 1800w" src="ncaa_count_attempts_unlog.png"/> 
</figure>

<p>We can clearly see at a glance that the majority of shots are made right in front of the basket. For 3-point shots, the center and the corners have higher numbers of shot attempts than the other areas. But not much else since the data is so spatially skewed: setting the bin color scale to logarithmic makes trends more apparent and helps things go viral on Reddit.</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_count_attempts_hu_3a087234886ce568.webp 320w,/2018/03/basketball-shots/ncaa_count_attempts_hu_31931a7d73c00179.webp 768w,/2018/03/basketball-shots/ncaa_count_attempts_hu_39e87b359975bcd4.webp 1024w,/2018/03/basketball-shots/ncaa_count_attempts.png 1800w" src="ncaa_count_attempts.png"/> 
</figure>

<p>Now there&rsquo;s more going on here: shot behavior is clearly symmetric on each side of the court, and there&rsquo;s a small gap between the 3-point line and where 3-pt shots are typically made, likely to ensure that it it&rsquo;s not accidentally ruled as a 2-pt shot.</p>
<p>How likely is it to score a shot from a given spot? Are certain spots better than others?</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_count_perc_success_hu_1a20df6dc8d568f.webp 320w,/2018/03/basketball-shots/ncaa_count_perc_success_hu_72c3f2cbec0a75d8.webp 768w,/2018/03/basketball-shots/ncaa_count_perc_success_hu_308287fdb103668e.webp 1024w,/2018/03/basketball-shots/ncaa_count_perc_success.png 1800w" src="ncaa_count_perc_success.png"/> 
</figure>

<p>Surprisingly, shot accuracy is about <em>equal</em> from anywhere within typical shooting distance, except directly in front of the basket where it&rsquo;s much higher. What is the <a href="https://en.wikipedia.org/wiki/Expected_value">expected value</a> of a shot at a given position: that is, how many points on average will they earn for their team?</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_count_avg_points_hu_cc6b1aabe2a1fbbd.webp 320w,/2018/03/basketball-shots/ncaa_count_avg_points_hu_48fa925084585c1d.webp 768w,/2018/03/basketball-shots/ncaa_count_avg_points_hu_e4e431e478a401a7.webp 1024w,/2018/03/basketball-shots/ncaa_count_avg_points.png 1800w" src="ncaa_count_avg_points.png"/> 
</figure>

<p>The average points earned for 3-pt shots is about 1.5x higher than many 2-pt shot locations in the inner court due to the equal accuracy, but locations next to the basket have an even higher expected value. Perhaps the accuracy of shots close to the basket is higher (&gt;1.5x) than 3-pt shots and outweighs the lower point value?</p>
<p>Since both sides of the court are indeed the same, we can combine the two sides and just plot a half-court instead. (Cross-court shots, which many Redditors <a href="https://www.reddit.com/r/dataisugly/comments/839rax/basketball_heat_map_shows_an_impressive_number_of/">argued</a> that they invalidated my visualizations above, constitute only <em>0.16%</em> of the basketball shots in the dataset, so they can be safely removed as outliers).</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_count_attempts_half_log_hu_1b25bb288c7845a4.webp 320w,/2018/03/basketball-shots/ncaa_count_attempts_half_log_hu_1c576186de477a2e.webp 768w,/2018/03/basketball-shots/ncaa_count_attempts_half_log_hu_f23437ee277976f3.webp 1024w,/2018/03/basketball-shots/ncaa_count_attempts_half_log.png 1200w" src="ncaa_count_attempts_half_log.png"/> 
</figure>

<p>There are still a few oddities, such as shots being made <em>behind</em> the basket. Let&rsquo;s drill down a bit.</p>
<h2 id="focusing-on-basketball-shot-type">Focusing on Basketball Shot Type</h2>
<p>The Sportradar dataset classifies a shot as one of 5 major types: a <strong>jump shot</strong> where the player jumps-and-throws the basketball, a <strong>layup</strong> where the player runs down the field toward the basket and throws a one-handed shot, a <strong>dunk</strong> where the player slams the ball into the basket (looking cool in the process), a <strong>hook shot</strong> where the player close to the basket throws the ball with a hook motion, and a <strong>tip shot</strong> where the player intercepts a basket rebound at the tip of the basket and pushes it in.</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_types_prop_attempts_hu_5b2e2e8111e12e08.webp 320w,/2018/03/basketball-shots/ncaa_types_prop_attempts_hu_ced73cb24cc6fc7d.webp 768w,/2018/03/basketball-shots/ncaa_types_prop_attempts_hu_baa56eb71d1a510d.webp 1024w,/2018/03/basketball-shots/ncaa_types_prop_attempts.png 1200w" src="ncaa_types_prop_attempts.png"/> 
</figure>

<p>However, the most frequent types of shots are the less flashy, more practical jump shots and layups. But is a certain type of shot &ldquo;better?&rdquo;</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_types_perc_hu_eddd49d65debceac.webp 320w,/2018/03/basketball-shots/ncaa_types_perc_hu_7ec71b6836db1818.webp 768w,/2018/03/basketball-shots/ncaa_types_perc_hu_bb58c5550052e5d8.webp 1024w,/2018/03/basketball-shots/ncaa_types_perc.png 1200w" src="ncaa_types_perc.png"/> 
</figure>

<p>Layups are safer than jump shots, but dunks are the most accurate of all the types (however, players likely wouldn&rsquo;t attempt a dunk unless they knew it would be successful). The accuracy of layups and other close-to-basket shots is indeed more than 1.5x better than the jump shots of 3-pt shots, which explains the expected value behavior above.</p>
<p>Plotting the heat maps for each type of shot offers more insight into how they work:</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_count_attempts_half_types_log_hu_f158f6e3a8368a14.webp 320w,/2018/03/basketball-shots/ncaa_count_attempts_half_types_log_hu_21a49f6411f78b6.webp 768w,/2018/03/basketball-shots/ncaa_count_attempts_half_types_log.png 900w" src="ncaa_count_attempts_half_types_log.png"/> 
</figure>

<p>They&rsquo;re wildly different heat maps which match the shot type descriptions above, but show we&rsquo;ll need to separate data visualizations by type to accurately see trends.</p>
<h2 id="impact-of-game-elapsed-time-at-time-of-shot">Impact of Game Elapsed Time At Time of Shot</h2>
<p>A NCAA basketball game lasts for 40 minutes total (2 halves of 20 minutes each), with the possibility of overtime. The <a href="https://bigquery.cloud.google.com/savedquery/4194148158:3359d86507814fb19a5997a770456baa">example BigQuery</a> for the NCAA-provided data compares the percentage of 3-point shots made during the first 35 minutes of the game versus the last 5 minutes: at the end of the game, accuracy was lower by 4 percentage points (31.2% vs. 35.1%). It might be interesting to facet these visualizations by the elapsed time of the game to see if there are any behavioral changes.</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_types_prop_type_elapsed_hu_bb28d87a78c18d3f.webp 320w,/2018/03/basketball-shots/ncaa_types_prop_type_elapsed_hu_b1bc08ac4dea3c7c.webp 768w,/2018/03/basketball-shots/ncaa_types_prop_type_elapsed_hu_d69cf0b659690837.webp 1024w,/2018/03/basketball-shots/ncaa_types_prop_type_elapsed.png 1200w" src="ncaa_types_prop_type_elapsed.png"/> 
</figure>

<p>There isn&rsquo;t much difference between the proportions within a given half, but there is a difference between the first half and the second half, where the second half has fewer jump shots and more aggressive layups and dunks. After looking at shot success percentage:</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_types_perc_success_type_elapsed_hu_92a660a371b13a60.webp 320w,/2018/03/basketball-shots/ncaa_types_perc_success_type_elapsed_hu_8687c28a1832735b.webp 768w,/2018/03/basketball-shots/ncaa_types_perc_success_type_elapsed_hu_de114505630e7a6f.webp 1024w,/2018/03/basketball-shots/ncaa_types_perc_success_type_elapsed.png 1200w" src="ncaa_types_perc_success_type_elapsed.png"/> 
</figure>

<p>The jump shot accuracy loss at the end of the game with Sportradar data is similar to that of the NCAA data, which is a good sanity check (but it&rsquo;s odd that the accuracy drop only happens in the last 5 minutes and not elsewhere in the 2nd half). Layup accuracy increases in the second half with the number of layups.</p>
<p>We can also visualize heat maps for each combo of shot type with time elapsed bucket, but given the results above, the changes in behavior over time may not be very perceptible.</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_count_attempts_half_interval_log_hu_87f66d471a4c95fc.webp 320w,/2018/03/basketball-shots/ncaa_count_attempts_half_interval_log_hu_d5cd2612709d9ea.webp 768w,/2018/03/basketball-shots/ncaa_count_attempts_half_interval_log_hu_8e1f44bad4069e9f.webp 1024w,/2018/03/basketball-shots/ncaa_count_attempts_half_interval_log.png 1200w" src="ncaa_count_attempts_half_interval_log.png"/> 
</figure>

<h2 id="impact-of-winninglosing-before-shot">Impact of Winning/Losing Before Shot</h2>
<p>Another theory worth exploring is determining if there is any difference whether a team is winning or losing when they make their shot (technically, when the delta between the team score and the other team score is positive for winning teams, negative for losing teams, or 0 if tied). Are players more relaxed when they have a lead? Are players more prone to making mistakes when losing?</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_types_prop_type_score_hu_29c29d850235c76d.webp 320w,/2018/03/basketball-shots/ncaa_types_prop_type_score_hu_4c6a81e571854d10.webp 768w,/2018/03/basketball-shots/ncaa_types_prop_type_score_hu_5205e23cfda70f5a.webp 1024w,/2018/03/basketball-shots/ncaa_types_prop_type_score.png 1200w" src="ncaa_types_prop_type_score.png"/> 
</figure>

<p>Layups are the same across all buckets, but for teams that are winning, there are fewer jump shots and <strong>more dunkin&rsquo; action</strong> (nearly double the dunks!). However, the accuracy chart illustrates an issue:</p>
<figure>

    <img loading="lazy" srcset="/2018/03/basketball-shots/ncaa_types_perc_success_type_score_hu_31d0201603d0a7d7.webp 320w,/2018/03/basketball-shots/ncaa_types_perc_success_type_score_hu_bafe4c92c10d1157.webp 768w,/2018/03/basketball-shots/ncaa_types_perc_success_type_score_hu_8e7746842c943e81.webp 1024w,/2018/03/basketball-shots/ncaa_types_perc_success_type_score.png 1200w" src="ncaa_types_perc_success_type_score.png"/> 
</figure>

<p>Accuracy for most types of shots is much better for teams that are winning&hellip;which may be the <em>reason</em> they&rsquo;re winning. More research can be done in this area.</p>
<h2 id="conclusion">Conclusion</h2>
<p>I fully admit I am not a basketball expert. But playing around with this data was a fun way to get a new perspective on how collegiate basketball games work. There&rsquo;s a lot more work that can be done with big basketball data and game strategy; the NCAA-provided data doesn&rsquo;t have location data, but it does have <strong>6x more shots</strong>, which will be very helpful for further fun in this area.</p>
<hr>
<p><em>You can view the R code, ggplot2 code, and BigQueries used to create the data visualizations in <a href="http://minimaxir.com/notebooks/basketball-shots/">this R Notebook</a>. You can also view the images/code used for this post in <a href="https://github.com/minimaxir/ncaa-basketball">this GitHub repository</a></em>.</p>
<p><em>You are free to use the data visualizations from this article however you wish, but it would be greatly appreciated if proper attribution is given to this article and/or myself!</em></p>
<p><em>Special thanks to Ewen Gallic for his implementation of a <a href="http://egallic.fr/en/drawing-a-basketball-court-with-r/">basketball court in ggplot2</a>, which saved me a lot of time!</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>A Visual Overview of Stack Overflow&#39;s Question Tags</title>
      <link>https://minimaxir.com/2018/02/stack-overflow-questions/</link>
      <pubDate>Fri, 09 Feb 2018 09:00:00 -0700</pubDate>
      <guid>https://minimaxir.com/2018/02/stack-overflow-questions/</guid>
      <description>I was surprised to see that all types of programming languages have quick answer times and a high probability of receiving an acceptable answer!</description>
      <content:encoded><![CDATA[<p><a href="https://stackoverflow.com">Stack Overflow</a> is the most popular contemporary knowledge base for programming questions. But most interact with the site by Googling a programming question and getting a top result that links to SO. There isn&rsquo;t as much discussion about actually <em>asking</em> questions on the site.</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/python_last_list_hu_25d38cdb30d0498f.webp 320w,/2018/02/stack-overflow-questions/python_last_list_hu_379aa50fc7ec9a0a.webp 768w,/2018/02/stack-overflow-questions/python_last_list_hu_28ba6b374bd5a225.webp 1024w,/2018/02/stack-overflow-questions/python_last_list.png 1686w" src="python_last_list.png"/> 
</figure>

<p>I <em>could</em> use <a href="https://stackoverflow.com/users/9314418/minimaxir?tab=profile">my Stack Overflow account</a> and test out the process of creating a question, but <del>I already know everything about programming</del> there may be another way to learn how SO works. Stack Overflow <a href="https://archive.org/details/stackexchange">releases an archive</a> of all questions on the site every 3 months, and this archive is <a href="https://cloud.google.com/bigquery/public-data/stackoverflow">syndicated to BigQuery</a>, making it trivial to retrieve and analyze the millions of SO questions over the years. Even though (now-former) Stack Overflow data scientist <a href="https://twitter.com/drob">David Robinson</a> has written <a href="https://stackoverflow.blog/2017/09/06/incredible-growth-python/">many</a> <a href="https://stackoverflow.blog/2017/04/19/programming-languages-used-late-night/">interesting</a> blog posts for Stack Overflow with their data, I figured why not give it a try.</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/python_last_list_answer_hu_eb0af2ca58a32eeb.webp 320w,/2018/02/stack-overflow-questions/python_last_list_answer_hu_a239dff4552731b7.webp 768w,/2018/02/stack-overflow-questions/python_last_list_answer_hu_c17d3dec6132cd9f.webp 1024w,/2018/02/stack-overflow-questions/python_last_list_answer.png 1670w" src="python_last_list_answer.png"/> 
</figure>

<h2 id="overview">Overview</h2>
<p>Unlike social media sites like <a href="https://twitter.com">Twitter</a> and <a href="https://www.reddit.com">Reddit</a> where the majority of traffic is driven within the first days after something is posted, posts on evergreen content sources like Stack Overflow are still relevant many years later. In fact, the traffic to Stack Overflow for most of 2017 (derived by finding the difference between question view counts from archive snapshots) is approximately uniform across question age, with a slight bias toward older content.</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/so_overview_hu_ccb1bb5b14e0f490.webp 320w,/2018/02/stack-overflow-questions/so_overview_hu_fd5456b53e8a3d50.webp 768w,/2018/02/stack-overflow-questions/so_overview_hu_b48cb8326f951666.webp 1024w,/2018/02/stack-overflow-questions/so_overview.png 1200w" src="so_overview.png"/> 
</figure>

<p>In 2017, Stack Overflow received about 40k-50k new questions each week, an impressive feat:</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/weekly_count_hu_f42f46bbf2c0045c.webp 320w,/2018/02/stack-overflow-questions/weekly_count_hu_adafdf8ff991a648.webp 768w,/2018/02/stack-overflow-questions/weekly_count_hu_20ee00d40fdeabb2.webp 1024w,/2018/02/stack-overflow-questions/weekly_count.png 1200w" src="weekly_count.png"/> 
</figure>

<p>For the rest of this post, we&rsquo;ll only look at questions made in 2017 (until December; about 2.3 million questions total) in order to get a sense of the current development landscape, and what&rsquo;s to come in the future. But what types of questions are they?</p>
<h2 id="tag-breakdown">Tag Breakdown</h2>
<p>All questions on Stack Overflow are required to have atleast 1 tag indicating the programming language/technologies involved with the question, and can have up to 5 tags. In the example &ldquo;how do you get the last element of a list in Python&rdquo; <a href="https://stackoverflow.com/questions/930397/getting-the-last-element-of-a-list-in-python">question</a> above, the tags are <code>python</code>, <code>list</code>, and <code>indexing</code>. In 2017, most of new questions had 2-3 tags. (i.e. people aren&rsquo;t <a href="http://minimaxir.com/2014/03/hashtag-tag/">tag spamming</a> like on <a href="https://www.instagram.com/?hl=en">Instagram</a> for maximum exposure).</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/so_tag_breakdown_hu_824c3cbac84d4ce6.webp 320w,/2018/02/stack-overflow-questions/so_tag_breakdown_hu_35c62637eb6e12ac.webp 768w,/2018/02/stack-overflow-questions/so_tag_breakdown_hu_41d81ccb55b35e25.webp 1024w,/2018/02/stack-overflow-questions/so_tag_breakdown.png 1200w" src="so_tag_breakdown.png"/> 
</figure>

<p>In theory, tag spamming might make a question more likely to be answered; however for all tag counts, the proportion of questions with accepted answer (the green checkmark) is <strong>36-39%</strong>, so there&rsquo;s not much practical benefit from minmaxing tag counts. Which types of tagged questions are most likely to be answered?</p>
<p>First, here&rsquo;s the breakdown of the top 40 tags on Stack Overflow, by the number of new questions containing that tag for each month throughout 2017. This can give a sense of each technology&rsquo;s growth/decline throughout the year.</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/monthly_count_tag_hu_ea69cdded812352f.webp 320w,/2018/02/stack-overflow-questions/monthly_count_tag_hu_10da23bf89790b71.webp 768w,/2018/02/stack-overflow-questions/monthly_count_tag_hu_67b73cc591239cf1.webp 1024w,/2018/02/stack-overflow-questions/monthly_count_tag.png 1800w" src="monthly_count_tag.png"/> 
</figure>

<p>Both new web development technologies like <code>reactjs</code> and <code>typescript</code> and data science tools like <code>pandas</code> and <code>r</code> are trending upward.</p>
<p>For the Top 1,000 tags, here are the top 30 tags by the proportion of questions which received an acceptable answer:</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/acceptable_answer_top_30_hu_3fe7bc1f073db8d2.webp 320w,/2018/02/stack-overflow-questions/acceptable_answer_top_30_hu_f71e8403d24ba45c.webp 768w,/2018/02/stack-overflow-questions/acceptable_answer_top_30_hu_e89eb6c7dcf96060.webp 1024w,/2018/02/stack-overflow-questions/acceptable_answer_top_30.png 1800w" src="acceptable_answer_top_30.png"/> 
</figure>

<p>In contrast, here are the bottom 30 out of the Top 1,000:</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/acceptable_answer_bottom_30_hu_97b990139e2d88c3.webp 320w,/2018/02/stack-overflow-questions/acceptable_answer_bottom_30_hu_e4bfbf35b53fc86b.webp 768w,/2018/02/stack-overflow-questions/acceptable_answer_bottom_30_hu_32acaa74db309a7c.webp 1024w,/2018/02/stack-overflow-questions/acceptable_answer_bottom_30.png 1800w" src="acceptable_answer_bottom_30.png"/> 
</figure>

<p>The top tags are newer, sexier technologies like <code>rust</code> and <code>dart</code>, with another strong hint of data science tooling with <code>dplyr</code> (which I used to aggregate the data for this post!) and <code>data.table</code>. In contrast, the bottom tags are less sexy and more corporate like <code>salesforce</code>, <code>drupal</code>, and <code>sharepoint-2013</code> (that&rsquo;s why consultants who specialize in these technologies can get paid very well!).</p>
<p>It should be noted these two charts do not necessarily imply that one technology is &ldquo;better&rdquo; than another, and the difference in answer rates may be due to question difficulty and the number of people skilled in the tech available that can answer it effectively.</p>
<p>The timing when questions are asked might vary by tag. Per <a href="https://stackoverflow.blog/2017/04/19/programming-languages-used-late-night/">a Stack Overflow analysis</a>, people typically ask questions during the 9 AM - 5 PM work hours (although in my case, I cannot easily adjust for the time zone of the asker). How does this data fare?</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/monthly_count_hr_doy_hu_fae4937bf0f0691e.webp 320w,/2018/02/stack-overflow-questions/monthly_count_hr_doy_hu_40befef30e7c85c5.webp 768w,/2018/02/stack-overflow-questions/monthly_count_hr_doy_hu_7c83e2680a6fe00.webp 1024w,/2018/02/stack-overflow-questions/monthly_count_hr_doy.png 1800w" src="monthly_count_hr_doy.png"/> 
</figure>

<p>This visualization is a bit weird. I adjusted the times to the Eastern time since internet activity for U.S.-based websites tends to revolve around that time zone. But for most technologies, the peak question-asking times are well before 9 AM to 5 PM: do those technologies correspond more to greater use in Europe and Asia? (In contrast, data-oriented technologies like <code>r</code>, <code>pandas</code> and <code>excel</code> <em>do</em> peak during the 9-5 block).</p>
<h2 id="how-easy-is-it-to-get-an-answer-by-tag">How easy is it to get an answer by tag?</h2>
<p>Stack Overflow caters the homepage toward the logged-in user&rsquo;s recommended tags. Therefore, it&rsquo;s not a surprise that the distribution of view counts on 2017 questions for each tag are very similar, although there is a slight edge toward the new &ldquo;hip&rdquo; technologies like <code>typescript</code>, <code>spring</code>, and <code>swift</code>.</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/views_boxplot_tag_hu_78b7bfb6f63173af.webp 320w,/2018/02/stack-overflow-questions/views_boxplot_tag_hu_add2aa16b5291c89.webp 768w,/2018/02/stack-overflow-questions/views_boxplot_tag_hu_f3845f5a14be4e23.webp 1024w,/2018/02/stack-overflow-questions/views_boxplot_tag.png 1800w" src="views_boxplot_tag.png"/> 
</figure>

<p>At the least, the distribution ensures that atleast 10 people see your question for these popular topics, which is nifty when you consider posts on Twitter and Reddit can die without any visibility at all. But will they provide an acceptable answer?</p>
<p>The time it takes to get an acceptable answer also varies significantly by tag:</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/acceptable_answer_density_hu_441417d4b0b9dbfd.webp 320w,/2018/02/stack-overflow-questions/acceptable_answer_density_hu_7e30755ce8384eeb.webp 768w,/2018/02/stack-overflow-questions/acceptable_answer_density_hu_538cf9d028958aed.webp 1024w,/2018/02/stack-overflow-questions/acceptable_answer_density.png 1800w" src="acceptable_answer_density.png"/> 
</figure>

<p>A median time of <em>15 minutes</em> for tags like <code>pandas</code> and <code>arrays</code> is pretty impressive! And even in the worst case scenario for these popular tags, the median is only a couple hours, much lower than I thought it would be.</p>
<h2 id="the-relationship-between-tags">The Relationship Between Tags</h2>
<p>As one would expect, the types of questions asked for each tag are much different. Here&rsquo;s a wordcloud for each of the tags, quantifying the words most frequently used in the questions on those tags:</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/so_tag_wordcloud_hu_8ba9e0f7676ec6b7.webp 320w,/2018/02/stack-overflow-questions/so_tag_wordcloud_hu_2078ca85488e7569.webp 768w,/2018/02/stack-overflow-questions/so_tag_wordcloud_hu_a7c21a23620e1454.webp 1024w,/2018/02/stack-overflow-questions/so_tag_wordcloud.png 1800w" src="so_tag_wordcloud.png"/> 
</figure>

<p>Notably, each word cloud is significantly different from reach other, even when technologies are related (also surprisingly true in the case of <code>angular</code> and <code>angularjs</code>!).</p>
<p>How are the tags related anyways? We can calculate an <a href="https://en.wikipedia.org/wiki/Adjacency_matrix">adjacency matrix</a> of the tag pairs in the questions to see which tags are related:</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/so_tag_adjacency_hu_6c0ca82329fcb525.webp 320w,/2018/02/stack-overflow-questions/so_tag_adjacency_hu_51742ee9039c83b6.webp 768w,/2018/02/stack-overflow-questions/so_tag_adjacency_hu_1cf472d92985bb8e.webp 1024w,/2018/02/stack-overflow-questions/so_tag_adjacency.png 1800w" src="so_tag_adjacency.png"/> 
</figure>

<p>Looking down a given row/column, you can see which technologies have a lot of questions in common with another (for example, <code>javascript</code> and <code>json</code> are frequently asked in conjunction with other tags).</p>
<p>Going back earlier to talking about tag abuse, do the presence of certain pairs of tags lead to notably different answer rates?</p>
<figure>

    <img loading="lazy" srcset="/2018/02/stack-overflow-questions/so_tag_adjacency_percent_hu_f1f8dada071ec058.webp 320w,/2018/02/stack-overflow-questions/so_tag_adjacency_percent_hu_89c242977eb1efb1.webp 768w,/2018/02/stack-overflow-questions/so_tag_adjacency_percent_hu_5603c58116c008e5.webp 1024w,/2018/02/stack-overflow-questions/so_tag_adjacency_percent.png 1800w" src="so_tag_adjacency_percent.png"/> 
</figure>

<p>Tag pairs which don&rsquo;t make much sense (e.g. <code>ios</code>+<code>android</code>, <code>ios</code>+<code>javascript</code>, <code>android</code>+<code>php</code>) tend to have very low answer rates (20%-30%). But tags with already high answer rates like <code>regex</code> don&rsquo;t get much higher or much lower at a given pair.</p>
<h2 id="conclusion">Conclusion</h2>
<p>There&rsquo;s a lot more than can be done looking at question tags on Stack Overflow. I was surprised to see that all types of programming languages have quick answer times and a high probability of receiving an acceptable answer! I&rsquo;ll definitely keep an eye on the SO archives as they are released, and I&rsquo;m excited to see how trends change in the future.</p>
<hr>
<p><em>You can view the R and ggplot2 code used to create the data visualizations in <a href="http://minimaxir.com/notebooks/stack-overflow-questions/">this R Notebook</a>. You can also view the images/data used for this post in <a href="https://github.com/minimaxir/stack-overflow-questions">this GitHub repository</a></em>.</p>
<p><em>You are free to use the data visualizations from this article however you wish, but it would be greatly appreciated if proper attribution is given to this article and/or myself!</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Predicting the Success of a Reddit Submission with Deep Learning and Keras</title>
      <link>https://minimaxir.com/2017/06/reddit-deep-learning/</link>
      <pubDate>Mon, 26 Jun 2017 09:00:00 -0700</pubDate>
      <guid>https://minimaxir.com/2017/06/reddit-deep-learning/</guid>
      <description>Thanks to Keras, performing deep learning on a very large number of Reddit submissions is actually pretty easy. Performing it &lt;em&gt;well&lt;/em&gt; is a different story.</description>
      <content:encoded><![CDATA[<p>I&rsquo;ve been trying to figure out what makes a <a href="https://www.reddit.com">Reddit</a> submission &ldquo;good&rdquo; for years. If we assume the number of upvotes on a submission is a fair proxy for submission quality, optimizing a statistical model for Reddit data with submission score as a response variable might lead to interesting (and profitable) insights when transferred into other domains, such as Facebook Likes and Twitter Favorites.</p>
<figure>

    <img loading="lazy" srcset="/2017/06/reddit-deep-learning/reddit-example_hu_ced286403a9a1f93.webp 320w,/2017/06/reddit-deep-learning/reddit-example_hu_25e458673cf9d615.webp 768w,/2017/06/reddit-deep-learning/reddit-example_hu_5d082790fbac9a8c.webp 1024w,/2017/06/reddit-deep-learning/reddit-example.png 1202w" src="reddit-example.png"/> 
</figure>

<p>An important part of a Reddit submission is the submission <strong>title</strong>. Like news headlines, a catchy title will make a user <a href="http://minimaxir.com/2015/10/reddit-topwords/">more inclined</a> to engage with a submission and potentially upvote.</p>
<p>Additionally, the <strong>time when the submission is made</strong> is <a href="http://minimaxir.com/2015/10/reddit-bigquery/">important</a>; submitting when user activity is the highest tends to lead to better results if you are trying to maximize exposure.</p>
<p>The actual <strong>content</strong> of the Reddit submission such as images/links to a website is likewise important, but good content is relatively difficult to optimize.</p>
<p>Can the magic of deep learning reconcile these concepts and create a model which can predict if a submission is a good submission? Thanks to <a href="https://github.com/fchollet/keras">Keras</a>, performing deep learning on a very large number of Reddit submissions is actually pretty easy. Performing it <em>well</em> is a different story.</p>
<h2 id="getting-the-data--feature-engineering">Getting the Data + Feature Engineering</h2>
<p>It&rsquo;s difficult to retrieve the content of millions of Reddit submissions at scale (ethically), so let&rsquo;s initially start by building a model using submissions on <a href="https://www.reddit.com/r/AskReddit/">/r/AskReddit</a>: Reddit&rsquo;s largest subreddit which receives 8,000+ submissions each day. /r/AskReddit is a self-post only subreddit with no external links, allowing us to focus on only the submission title and timing.</p>
<p><a href="http://minimaxir.com/2015/10/reddit-bigquery/">As always</a>, we can collect large amounts of Reddit data from the public Reddit dataset on <a href="https://cloud.google.com/bigquery/">BigQuery</a>. The submission <code>title</code> is available by default. The raw timestamp of the submission is also present, allowing us to extract the <code>hour</code> of submission (adjusted to Eastern Standard Time) and <code>dayofweek</code>, as used in the heatmap above. But why stop there? Since /r/AskReddit receives hundreds of submissions <em>every hour</em> on average, we should look at the <code>minute</code> level to see if there are any deeper trends (e.g. there are only 30 slots available on the first page of /new and since there is so much submission activity, it might be more advantageous to submit during off-peak times). Lastly, to account for potential changes in behavior as the year progresses, we should add a <code>dayofyear</code> feature, where January 1st = 1, January 2nd = 2, etc which can also account for variance due to atypical days like holidays.</p>
<p>Instead of predicting the raw number on upvotes of the Reddit submission (as the distribution of submission scores is heavily skewed), we should predict <strong>whether or not the submission is good</strong>, shaping the problem as a <a href="https://en.wikipedia.org/wiki/Logistic_regression">logistic regression</a>. In this case, let&rsquo;s define a &ldquo;good submission&rdquo; as one whose score is equal to or above the <strong>50th percentile (median) of all submissions</strong> in /r/AskReddit. Unfortunately, the median score ends up being <strong>2 points</strong>; although &ldquo;one upvote&rdquo; might be a low threshold for a &ldquo;good&rdquo; submission, it splits the dataset into 64% bad submissions, 36% good submissions, and setting the percentile threshold higher will result in a very unbalanced dataset for model training (a score of 2+ also implies that the submission did not get downvoted to death, which is useful).</p>
<p>Gathering all <strong>976,538 /r/AskReddit submissions</strong> from January 2017 to April 2017 should be enough data for this project. Here&rsquo;s the final BigQuery:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="o">#</span><span class="n">standardSQL</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">title</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">CAST</span><span class="p">(</span><span class="n">FORMAT_TIMESTAMP</span><span class="p">(</span><span class="s1">&#39;%H&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">TIMESTAMP_SECONDS</span><span class="p">(</span><span class="n">created_utc</span><span class="p">),</span><span class="w"> </span><span class="s1">&#39;America/New_York&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">INT64</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">hour</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">CAST</span><span class="p">(</span><span class="n">FORMAT_TIMESTAMP</span><span class="p">(</span><span class="s1">&#39;%M&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">TIMESTAMP_SECONDS</span><span class="p">(</span><span class="n">created_utc</span><span class="p">),</span><span class="w"> </span><span class="s1">&#39;America/New_York&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">INT64</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">minute</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">CAST</span><span class="p">(</span><span class="n">FORMAT_TIMESTAMP</span><span class="p">(</span><span class="s1">&#39;%w&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">TIMESTAMP_SECONDS</span><span class="p">(</span><span class="n">created_utc</span><span class="p">),</span><span class="w"> </span><span class="s1">&#39;America/New_York&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">INT64</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">dayofweek</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">CAST</span><span class="p">(</span><span class="n">FORMAT_TIMESTAMP</span><span class="p">(</span><span class="s1">&#39;%j&#39;</span><span class="p">,</span><span class="w"> </span><span class="n">TIMESTAMP_SECONDS</span><span class="p">(</span><span class="n">created_utc</span><span class="p">),</span><span class="w"> </span><span class="s1">&#39;America/New_York&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">INT64</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">dayofyear</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">IF</span><span class="p">(</span><span class="n">PERCENT_RANK</span><span class="p">()</span><span class="w"> </span><span class="n">OVER</span><span class="w"> </span><span class="p">(</span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">score</span><span class="w"> </span><span class="k">ASC</span><span class="p">)</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">0</span><span class="p">.</span><span class="mi">50</span><span class="p">,</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">is_top_submission</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="o">`</span><span class="n">fh</span><span class="o">-</span><span class="n">bigquery</span><span class="p">.</span><span class="n">reddit_posts</span><span class="p">.</span><span class="o">*`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">WHERE</span><span class="w"> </span><span class="p">(</span><span class="n">_TABLE_SUFFIX</span><span class="w"> </span><span class="k">BETWEEN</span><span class="w"> </span><span class="s1">&#39;2017_01&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="s1">&#39;2017_04&#39;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">subreddit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;AskReddit&#39;</span><span class="w">
</span></span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2017/06/reddit-deep-learning/bigquery_hu_89adb35f6f1860d6.webp 320w,/2017/06/reddit-deep-learning/bigquery_hu_bb2e955b3cb7daeb.webp 768w,/2017/06/reddit-deep-learning/bigquery_hu_fa76341d390d603.webp 1024w,/2017/06/reddit-deep-learning/bigquery.png 2104w" src="bigquery.png"/> 
</figure>

<h2 id="model-architecture">Model Architecture</h2>
<p><em>If you want to see the detailed data transformations and Keras code examples/outputs for this post, you can view <a href="https://github.com/minimaxir/predict-reddit-submission-success/blob/master/predict_askreddit_submission_success_timing.ipynb">this Jupyter Notebook</a>.</em></p>
<p>Text processing is a good use case for deep learning, as it can identify relationships between words where older methods like <a href="https://en.wikipedia.org/wiki/Tf%E2%80%93idf">tf-idf</a> can&rsquo;t. Keras, a high level deep-learning framework on top of lower frameworks like <a href="https://www.tensorflow.org">TensorFlow</a>, can easily convert a list of texts to a <a href="https://keras.io/preprocessing/sequence/">padded sequence</a> of <a href="https://keras.io/preprocessing/text/">index tokens</a> that can interact with deep learning models, along with many other benefits. Data scientists often use <a href="https://en.wikipedia.org/wiki/Recurrent_neural_network">recurrent neural networks</a> that can &ldquo;learn&rdquo; for classifying text. However <a href="https://github.com/facebookresearch/fastText">fasttext</a>, a newer algorithm from researchers at Facebook, can perform classification tasks at an <a href="http://minimaxir.com/2017/06/keras-cntk/">order of magnitude faster</a> training time than RNNs, with similar predictive performance.</p>
<p>fasttext works by <a href="https://arxiv.org/abs/1607.01759">averaging word vectors</a>. In this Reddit model architecture inspired by the <a href="https://github.com/fchollet/keras/blob/master/examples/imdb_fasttext.py">official Keras fasttext example</a>, each word in a Reddit submission title (up to 20) is mapped to a 50-dimensional vector from an Embeddings layer of up to 40,000 words. The Embeddings layer is <a href="https://blog.keras.io/using-pre-trained-word-embeddings-in-a-keras-model.html">initialized</a> with <a href="https://nlp.stanford.edu/projects/glove/">GloVe word embeddings</a> pre-trained on billions of words to give the model a good start. All the word vectors for a given Reddit submission title are averaged together, and then a Dense fully-connected layer outputs a probability the given text is a good submission. The gradients then backpropagate and improve the word embeddings for future batches during training.</p>
<p>Keras has a <a href="https://keras.io/visualization/">convenient utility</a> to visualize deep learning models:</p>
<figure>

    <img loading="lazy" srcset="/2017/06/reddit-deep-learning/model_shapes-1_hu_b9f7a08f534a0b45.webp 320w,/2017/06/reddit-deep-learning/model_shapes-1.png 663w" src="model_shapes-1.png"/> 
</figure>

<p>However, the first output above is the <em>auxiliary output</em> for <a href="https://en.wikipedia.org/wiki/Regularization_%28mathematics%29">regularizing</a> the word embeddings; we still have to incorporate the submission timing data into the model.</p>
<p>Each of the four timing features (hour, minute, day of week, day of year) receives its own Embeddings layer, outputting a 64D vector. This allows the features to learn latent characteristics which may be missed using traditional <a href="http://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.OneHotEncoder.html">one-hot encoding</a> for categorical data in machine learning problems.</p>
<figure>

    <img loading="lazy" srcset="/2017/06/reddit-deep-learning/model_shapes-2_hu_52d718feedd74c43.webp 320w,/2017/06/reddit-deep-learning/model_shapes-2_hu_84d0630736ebd887.webp 768w,/2017/06/reddit-deep-learning/model_shapes-2_hu_f74f2c7dacf4dc23.webp 1024w,/2017/06/reddit-deep-learning/model_shapes-2.png 1754w" src="model_shapes-2.png"/> 
</figure>

<p>The 50D word average vector is concatenated with the four vectors above, resulting in a 306D vector. This combined vector is connected to another fully-connected layer which can account for hidden interactions between all five input features (plus <a href="https://keras.io/layers/normalization/">batch normalization</a>, which improves training speed for Dense layers). Then the model outputs a final probability prediction: the <em>main output</em>.</p>
<figure>

    <img loading="lazy" srcset="/2017/06/reddit-deep-learning/model_shapes-3_hu_d2ecf94768050fa.webp 320w,/2017/06/reddit-deep-learning/model_shapes-3_hu_e208de51b840cc8a.webp 768w,/2017/06/reddit-deep-learning/model_shapes-3.png 852w" src="model_shapes-3.png"/> 
</figure>

<p>The final model:</p>
<figure>

    <img loading="lazy" srcset="/2017/06/reddit-deep-learning/model_hu_ea04eea1eca03032.webp 320w,/2017/06/reddit-deep-learning/model_hu_6adb1a1bee6dfcb9.webp 768w,/2017/06/reddit-deep-learning/model_hu_b6ceee5bdac0e8e1.webp 1024w,/2017/06/reddit-deep-learning/model.png 1350w" src="model.png"/> 
</figure>

<p>All of this sounds difficult to implement, but Keras&rsquo;s <a href="https://keras.io/getting-started/functional-api-guide/">functional API</a> ensures that adding each layer and linking them together can be done in a single line of code each.</p>
<h2 id="training-results">Training Results</h2>
<p>Because the model uses no recurrent layers, it trains fast enough on a CPU despite the large dataset size.</p>
<p>We split the full dataset into 80%/20% training/test datasets, training the model on the former and testing the model against the latter. Keras trains a model with a simple <code>fit</code> command and trains for 20 epochs, where one epoch represents an entire pass of the training set.</p>
<figure>

    <img loading="lazy" srcset="/2017/06/reddit-deep-learning/fit_hu_c4b22cd471fd6b14.webp 320w,/2017/06/reddit-deep-learning/fit_hu_25fedd4b89849374.webp 768w,/2017/06/reddit-deep-learning/fit_hu_408a494d98bce4d7.webp 1024w,/2017/06/reddit-deep-learning/fit.png 1236w" src="fit.png"/> 
</figure>

<p>There&rsquo;s a lot happening in the console output due to the architecture, but the main metrics of interest are the <code>main_out_acc</code>, the accuracy of the training set through the main output, and <code>val_main_out_acc</code>, the accuracy of the test set. Ideally, the accuracy of both should increase as training progresses. However, the test accuracy <em>must</em> be better than the 64% baseline (if we just say all /r/AskReddit submissions are bad), otherwise this model is unhelpful.</p>
<p>Keras&rsquo;s <a href="https://keras.io/callbacks/#csvlogger">CSVLogger</a> trivially logs all these metrics to a CSV file. Plotting the results of the 20 epochs:</p>
<figure>

    <img loading="lazy" srcset="/2017/06/reddit-deep-learning/predict-reddit-1_hu_5671c8a5110b2d25.webp 320w,/2017/06/reddit-deep-learning/predict-reddit-1_hu_dab24707e22e81d.webp 768w,/2017/06/reddit-deep-learning/predict-reddit-1_hu_325aebfe1b36135c.webp 1024w,/2017/06/reddit-deep-learning/predict-reddit-1.png 1200w" src="predict-reddit-1.png"/> 
</figure>

<p>The test accuracy does indeed beat the 64% baseline; however, test accuracy <em>decreases</em> as training progresses. This is a sign of <a href="https://en.wikipedia.org/wiki/Overfitting">overfitting</a>, possibly due to the potential disparity between texts in the training and test sets. In deep learning, you can account for overfitting by adding <a href="https://keras.io/layers/core/#dropout">Dropout</a> to relevant layers, but in my testing it did not help.</p>
<h2 id="using-the-model-to-optimize-reddit-submissions">Using The Model To Optimize Reddit Submissions</h2>
<p>At the least, we now have a model that understands the latent characteristics of an /r/AskReddit submission. But how do you apply the model <em>in practical, real-world situations</em>?</p>
<p>Let&rsquo;s take a random /r/AskReddit submission: <a href="https://www.reddit.com/r/AskReddit/comments/5odcpd/which_movies_plot_would_drastically_change_if_you/">Which movie&rsquo;s plot would drastically change if you removed a letter from its title?</a>, submitted Monday, January 16th at 3:46 PM EST and receiving 4 upvotes (a &ldquo;good&rdquo; submission in context of this model). Plugging those input variables into the trained model results in a <strong>0.669</strong> probability of it being considered a good submission, which is consistent with the true results.</p>
<p>But what if we made <em>minor, iterative changes</em> to the title while keeping the time submitted unchanged? Can we improve this probability?</p>
<p>&ldquo;Drastically&rdquo; is a silly adjective; removing it and using the title <strong>Which movie&rsquo;s plot would change if you removed a letter from its title?</strong> results in a greater probability of <strong>0.682</strong>.</p>
<p>&ldquo;Removed&rdquo; is <a href="http://www.ef.edu/english-resources/english-grammar/conditional/">grammatically incorrect</a>; fixing the issue and using the title <strong>Which movie&rsquo;s plot would change if you remove a letter from its title?</strong> results in a greater probability of <strong>0.692</strong>.</p>
<p>&ldquo;Which&rdquo; is also <a href="https://www.englishclub.com/vocabulary/wh-question-words.htm">grammatically incorrect</a>; fixing the issue and using the title <strong>What movie&rsquo;s plot would change if you remove a letter from its title?</strong> results in a greater probability of <strong>0.732</strong>.</p>
<p>Although adjectives are sometimes redundant, they can add an intriguing emphasis; adding a &ldquo;single&rdquo; and using the title <strong>What movie&rsquo;s plot would change if you remove a single letter from its title?</strong> results in a greater probability of <strong>0.753</strong>.</p>
<p>Not bad for a little workshopping!</p>
<p>Now that we have an improved title, we can find an optimal time to make the submission through brute force by calculating the probabilities for all combinations of hour, minute, and day of week (and offsetting the day of year appropriately). After doing so, I discovered that making the submission on the previous Sunday at 10:55 PM EST results in the maximum probability possible of being a good submission at <strong>0.841</strong> (the other top submission times are at various other minutes during that hour; the best time on a different day is the following Tuesday at 4:05 AM EST with a probability of <strong>0.823</strong>).</p>
<p>In all, this model of Reddit submission success prediction is a proof of concept; there are many, <em>many</em> optimizations that can be done on the feature engineering side and on the data collection side (especially if we want to model subreddits other than /r/AskReddit). Predicting which submissions go viral instead of just predicting which submissions receive atleast one upvote is another, more advanced problem entirely.</p>
<p>Thanks to the high-level abstractions and utility functions of Keras, I was able to prototype the initial model in an afternoon instead of the weeks/months required for academic papers and software applications in this area. At the least, this little experiment serves as an example of applying Keras to a real-world dataset, and the tradeoffs that result when deep learning can&rsquo;t magically solve everything. But that doesn&rsquo;t mean my experiments on the Reddit data were unproductive; on the contrary, I now have a few new clever ideas how to fix some of the issues discovered, which I hope to implement soon.</p>
<p>Again, I strongly recommend reading the data transformations and Keras code examples in <a href="https://github.com/minimaxir/predict-reddit-submission-success/blob/master/predict_askreddit_submission_success_timing.ipynb">this Jupyter Notebook</a> for more information into the methodology, as building modern deep learning models is more intuitive and less arcane than what thought pieces on Medium imply.</p>
<hr>
<p><em>You can view the R and ggplot2 code used to visualize the model data in <a href="http://minimaxir.com/notebooks/predict-reddit-submission-success/">this R Notebook</a>, including 2D projections of the Embedding layers not in this article. You can also view the images/data used for this post in <a href="https://github.com/minimaxir/predict-reddit-submission-success">this GitHub repository</a>.</em></p>
<p><em>You are free to use the data visualizations/model architectures from this article however you wish, but it would be greatly appreciated if proper attribution is given to this article and/or myself!</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>The Decline of Imgur on Reddit and the Rise of Reddit&#39;s Native Image Hosting</title>
      <link>https://minimaxir.com/2017/06/imgur-decline/</link>
      <pubDate>Tue, 20 Jun 2017 08:00:00 -0700</pubDate>
      <guid>https://minimaxir.com/2017/06/imgur-decline/</guid>
      <description>Before Reddit added native image hosting, Imgur accounted for 15% of all submissions to Reddit. Now it&amp;rsquo;s below 9%.</description>
      <content:encoded><![CDATA[<p>Last week, Bloomberg <a href="https://www.bloomberg.com/news/articles/2017-06-17/reddit-said-to-be-raising-funds-valuing-startup-at-1-7-billion">reported</a> that Reddit was raising about $150 Million in venture capital at a valuation of $1.7 billion. Since Reddit&rsquo;s data is <a href="http://minimaxir.com/2015/10/reddit-bigquery/">public on BigQuery</a>, I quickly checked if there were any recent user engagement growth spurts which could justify such a high worth. Here&rsquo;s an example BigQuery which aggregates the total number of Reddit submissions made for each month until the end of April 2017:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="o">#</span><span class="n">standardSQL</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">DATE_TRUNC</span><span class="p">(</span><span class="nb">DATE</span><span class="p">(</span><span class="n">TIMESTAMP_SECONDS</span><span class="p">(</span><span class="n">created_utc</span><span class="p">)),</span><span class="w"> </span><span class="k">MONTH</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">mon</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="n">num_submissions</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="o">`</span><span class="n">fh</span><span class="o">-</span><span class="n">bigquery</span><span class="p">.</span><span class="n">reddit_posts</span><span class="p">.</span><span class="o">*`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">WHERE</span><span class="w"> </span><span class="p">(</span><span class="n">_TABLE_SUFFIX</span><span class="w"> </span><span class="k">BETWEEN</span><span class="w"> </span><span class="s1">&#39;2016_01&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="s1">&#39;2017_04&#39;</span><span class="w"> </span><span class="k">OR</span><span class="w"> </span><span class="n">_TABLE_SUFFIX</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;full_corpus_201512&#39;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">mon</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">ORDER</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">mon</span><span class="w">
</span></span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2017/06/imgur-decline/reddit-1_hu_50c3dc5d7726f37e.webp 320w,/2017/06/imgur-decline/reddit-1_hu_3424cd96f290c9b5.webp 768w,/2017/06/imgur-decline/reddit-1_hu_d72ee57a46a0b1c1.webp 1024w,/2017/06/imgur-decline/reddit-1.png 1500w" src="reddit-1.png"/> 
</figure>

<p>As it turns out, Reddit did indeed get a large boost in activity toward the end of 2016, likely due to the <em>heated</em> discussions and events around the <a href="https://en.wikipedia.org/wiki/United_States_presidential_election,_2016">U.S. Presidential Election</a>. But Reddit has maintained the growth rate since then, which is very appealing to potential investors.</p>
<p>How are other sites benefiting from Reddit&rsquo;s growth? <a href="http://imgur.com">Imgur</a>, an image-host developed to be the <em>de facto</em> image hosting service for Reddit, shared in Reddit&rsquo;s continual growth&hellip;</p>
<figure>

    <img loading="lazy" srcset="/2017/06/imgur-decline/reddit-2_hu_f32c314c9db02d44.webp 320w,/2017/06/imgur-decline/reddit-2_hu_3de34fd78ea0813b.webp 768w,/2017/06/imgur-decline/reddit-2_hu_e9950c21b9870ca4.webp 1024w,/2017/06/imgur-decline/reddit-2.png 1500w" src="reddit-2.png"/> 
</figure>

<p>&hellip;until mid-2016, when Imgur submission activity abruptly dropped. What happened?</p>
<p>Coincidentally in mid-2016, Reddit <a href="https://techcrunch.com/2016/05/25/reddit-image-uploads/">made itself</a> an image host for submissions to the site. Initially limited to uploads via the iOS/Android apps, Reddit then allowed desktop users to upload images through a <a href="https://www.reddit.com/r/changelog/comments/4kuk2j/reddit_change_introducing_image_uploading_beta/">beta rollout</a> starting May 24th, and a full <a href="https://www.reddit.com/r/announcements/comments/4p5dm9/image_hosting_on_reddit/">sitewide release</a> on June 21st.</p>
<p>How many Reddit-hosted image submissions are there compared to the number of Imgur submissions?</p>
<figure>

    <img loading="lazy" srcset="/2017/06/imgur-decline/reddit-3_hu_e253b95a38033adc.webp 320w,/2017/06/imgur-decline/reddit-3_hu_b700c86581397587.webp 768w,/2017/06/imgur-decline/reddit-3_hu_f675746a4ace3aec.webp 1024w,/2017/06/imgur-decline/reddit-3.png 1500w" src="reddit-3.png"/> 
</figure>

<p>Wow, native Reddit images caught on.</p>
<h2 id="market-share">Market Share</h2>
<figure>

    <img loading="lazy" srcset="/2017/06/imgur-decline/pics_hu_254562dd20df73be.webp 320w,/2017/06/imgur-decline/pics_hu_a0d29b8f5ec323e1.webp 768w,/2017/06/imgur-decline/pics_hu_822ab094826f3973.webp 1024w,/2017/06/imgur-decline/pics.png 1101w" src="pics.png"/> 
</figure>

<p>Did the rise of Reddit-hosted images cause the decline of Imgur on Reddit? Let&rsquo;s look at the daily number of Imgur submissions and Reddit-hosted Image submissions from December 2015 to April 2017, normalized by the total number of sitewide submissions on that day. This gives us a Reddit &ldquo;market share&rdquo; metric for both services.</p>
<p>Additionally, we can plot vertical lines representing the dates when Reddit-hosted images rolled out in the limited beta release and the full sitewide release to see if there is a link between those events and submission behavior.</p>
<figure>

    <img loading="lazy" srcset="/2017/06/imgur-decline/reddit-4_hu_4ee09338a5791411.webp 320w,/2017/06/imgur-decline/reddit-4_hu_6c7245f5f940c2e1.webp 768w,/2017/06/imgur-decline/reddit-4_hu_7cb95dbaf55d9d98.webp 1024w,/2017/06/imgur-decline/reddit-4.png 1200w" src="reddit-4.png"/> 
</figure>

<p>Before Reddit added native image hosting, Imgur accounted for 15% of all submissions to Reddit. Now it&rsquo;s below 9%. More Reddit-hosted images are being shared on Reddit than images from Imgur.</p>
<p>Instead of looking at all of Reddit, where spam subreddits could skew the results, we can also look at the largest image-only subreddits: <a href="https://www.reddit.com/r/pics/">/r/pics</a> and <a href="https://www.reddit.com/r/gifs/">/r/gifs</a>, both of which were a part of the beta rollout.</p>
<figure>

    <img loading="lazy" srcset="/2017/06/imgur-decline/reddit-5_hu_f39f160457e2df18.webp 320w,/2017/06/imgur-decline/reddit-5_hu_aaa929a44f375cda.webp 768w,/2017/06/imgur-decline/reddit-5_hu_f4911f05f33ceed5.webp 1024w,/2017/06/imgur-decline/reddit-5.png 1200w" src="reddit-5.png"/> 
</figure>

<p>Here, the impact of the two rollouts is much noticeable, with immediate increases in Reddit-hosted image market share after each rollout, and proportional decreases in Imgur market share. The growth rate after the beta release is flat for both services, but when Reddit image hosting becomes sitewide, the market shares of Reddit-hosted/Imgur images increase/decrease linearly over time once users officially learn that the native image upload functionality exists. And these trends do not appear to be slowing down.</p>
<h2 id="a-silver-lining">A Silver Lining?</h2>
<p>Obviously Imgur does not like losing a <em>large</em> chunk of traffic, but there&rsquo;s a possibility that this outcome will be better for the business than what&rsquo;s implied from the charts above.</p>
<p>Hosting images on the internet isn&rsquo;t free, and bandwidth costs are the primary reason dedicated image hosts have died off over the years. Direct image links which show the user only the image and nothing else are convenient, but they are pure loss for the service. That&rsquo;s why image hosts encourage linking to the image on a landing page of the website, filled with ads which generate an expected revenue greater than the cost of serving the image.</p>
<p>After a user uploads an image to Imgur on the desktop, the user is given two share links that can be submitted to sites like Reddit: an image link that goes to the image + ads, and a direct link to the image.</p>
<figure>

    <img loading="lazy" srcset="/2017/06/imgur-decline/imgur_direct_hu_4e7b2a396ae5e6bf.webp 320w,/2017/06/imgur-decline/imgur_direct_hu_290e7d38ff430219.webp 768w,/2017/06/imgur-decline/imgur_direct.png 991w" src="imgur_direct.png"/> 
</figure>

<p>Recently, Imgur has <a href="https://www.reddit.com/r/assholedesign/comments/5gs96k/just_show_me_the_fucking_image_imgur/">pushed app downloads</a> when visiting the site on an iOS/Android device, including <a href="https://www.reddit.com/r/assholedesign/comments/695efj/upload_image_on_imgur_mobile_has_been_replaced_by/">disabling uploads</a> in the mobile browser. When sharing an image from the Imgur app, the <em>only</em> way to share an image is through the image link, which could lead to an increase in the proportion of ad-filled Imgur image links on Reddit. Said increase could counteract the decrease in total Imgur submissions, and Imgur could actually come out ahead.</p>
<p>With BigQuery, we can check the percentage of all Imgur submissions to Reddit which are direct links and the percentage which are indirect/lead to a landing page, and see if the ratio changes along the same time horizon used above:</p>
<figure>

    <img loading="lazy" srcset="/2017/06/imgur-decline/reddit-6_hu_f1c47ff2cd14f4d3.webp 320w,/2017/06/imgur-decline/reddit-6_hu_7baf41c4d88bcb6a.webp 768w,/2017/06/imgur-decline/reddit-6_hu_822a82d187387670.webp 1024w,/2017/06/imgur-decline/reddit-6.png 1200w" src="reddit-6.png"/> 
</figure>

<p>Welp. No significant change in the ratio over time, eliminating that possible silver lining.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Note that the decline of Imgur on Reddit says nothing about Imgur as a business; it&rsquo;s entirely possible that Imgur&rsquo;s traffic on the main site itself is sufficient for growth. But the loss of Reddit traffic certainly can&rsquo;t be ignored, and it&rsquo;s interesting to visualize how quickly a service can be replaced when there&rsquo;s an equivalent native feature.</p>
<p>It&rsquo;s worth nothing that new competitors in the image space such as <a href="https://giphy.com">Giphy</a> utilize image hosting as a <em>secondary</em> service. Instead, they focus on building a repository of images which can be licensed and accessed programmatically by other services like Slack, Facebook, and Twitter. And Giphy has raised <a href="https://www.crunchbase.com/organization/giphy#/entity">$150 Million</a> total with this approach, so perhaps the image hosting market itself has indeed changed.</p>
<hr>
<p><em>You can view the R, ggplot2 code, and BigQueries used to visualize the Reddit data in <a href="http://minimaxir.com/notebooks/imgur-decline/">this R Notebook</a>. You can also view the images/data used for this post in <a href="https://github.com/minimaxir/imgur-decline">this GitHub repository</a></em>.</p>
<p><em>You are free to use the data visualizations from this article however you wish, but it would be greatly appreciated if proper attribution is given to this article and/or myself!</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Playing with 80 Million Amazon Product Review Ratings Using Apache Spark</title>
      <link>https://minimaxir.com/2017/01/amazon-spark/</link>
      <pubDate>Mon, 02 Jan 2017 09:00:00 -0700</pubDate>
      <guid>https://minimaxir.com/2017/01/amazon-spark/</guid>
      <description>Manipulating actually-big-data is just as easy as performing an analysis on a dataset with only a few records.</description>
      <content:encoded><![CDATA[<p><a href="https://www.amazon.com">Amazon</a> product reviews and ratings are a very important business. Customers on Amazon often make purchasing decisions based on those reviews, and a single bad review can cause a potential purchaser to reconsider. A couple years ago, I wrote a blog post titled <a href="http://minimaxir.com/2014/06/reviewing-reviews/">A Statistical Analysis of 1.2 Million Amazon Reviews</a>, which was well-received.</p>
<p>Back then, I was only limited to 1.2M reviews because attempting to process more data caused out-of-memory issues and my R code took <em>hours</em> to run.</p>
<p><a href="http://spark.apache.org">Apache Spark</a>, which makes processing gigantic amounts of data efficient and sensible, has become very popular in the past couple years (for good tutorials on using Spark with Python, I recommend the <a href="https://courses.edx.org/courses/course-v1:BerkeleyX&#43;CS105x&#43;1T2016/info">free</a> <a href="https://courses.edx.org/courses/course-v1:BerkeleyX&#43;CS110x&#43;2T2016/info">eDX</a> <a href="https://courses.edx.org/courses/course-v1:BerkeleyX&#43;CS120x&#43;2T2016/info">courses</a>). Although data scientists often use Spark to process data with distributed cloud computing via <a href="https://aws.amazon.com/ec2/">Amazon EC2</a> or <a href="https://azure.microsoft.com/en-us/services/hdinsight/apache-spark/">Microsoft Azure</a>, Spark works just fine even on a typical laptop, given enough memory (for this post, I use a 2016 MacBook Pro/16GB RAM, with 8GB allocated to the Spark driver).</p>
<p>I wrote a <a href="https://github.com/minimaxir/amazon-spark/blob/master/amazon_preprocess.py">simple Python script</a> to combine the per-category ratings-only data from the <a href="http://jmcauley.ucsd.edu/data/amazon/">Amazon product reviews dataset</a> curated by Julian McAuley, Rahul Pandey, and Jure Leskovec for their 2015 paper <a href="http://cseweb.ucsd.edu/~jmcauley/pdfs/kdd15.pdf">Inferring Networks of Substitutable and Complementary Products</a>. The result is a 4.53 GB CSV that would definitely not open in Microsoft Excel. The truncated and combined dataset includes the <strong>user_id</strong> of the user leaving the review, the <strong>item_id</strong> indicating the Amazon product receiving the review, the <strong>rating</strong> the user gave the product from 1 to 5, and the <strong>timestamp</strong> indicating the time when the review was written (truncated to the Day). We can also infer the <strong>category</strong> of the reviewed product from the name of the data subset.</p>
<p>Afterwards, using the new <a href="http://spark.rstudio.com">sparklyr</a> package for R, I can easily start a local Spark cluster with a single <code>spark_connect()</code> command and load the entire CSV into the cluster in seconds with a single <code>spark_read_csv()</code> command.</p>
<figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/output_hu_ec8eea9b3081c1c7.webp 320w,/2017/01/amazon-spark/output_hu_8270512f3a7c1a2d.webp 768w,/2017/01/amazon-spark/output_hu_4b84f8ec97e28a5d.webp 1024w,/2017/01/amazon-spark/output.png 1106w" src="output.png"/> 
</figure>

<p>There are 80.74 million records total in the dataset, or as the output helpfully reports, <code>8.074e+07</code> records. Performing advanced queries with traditional tools like <a href="https://cran.rstudio.com/web/packages/dplyr/vignettes/introduction.html">dplyr</a> or even Python&rsquo;s <a href="http://pandas.pydata.org">pandas</a> on such a dataset would take a considerable amount of time to execute.</p>
<p>With sparklyr, manipulating actually-big-data is <em>just as easy</em> as performing an analysis on a dataset with only a few records (and an order of magnitude easier than the Python approaches taught in the eDX class mentioned above!).</p>
<h2 id="exploratory-analysis">Exploratory Analysis</h2>
<p><em>(You can view the R code used to process the data with Spark and generate the data visualizations in <a href="http://minimaxir.com/notebooks/amazon-spark/">this R Notebook</a>)</em></p>
<p>There are <strong>20,368,412</strong> unique users who provided reviews in this dataset. <strong>51.9%</strong> of those users have only written one review.</p>
<figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/user_count_cum_hu_54895d3a9ab17726.webp 320w,/2017/01/amazon-spark/user_count_cum_hu_7225760c4d310a5d.webp 768w,/2017/01/amazon-spark/user_count_cum_hu_ce06c1ed7757f2bc.webp 1024w,/2017/01/amazon-spark/user_count_cum.png 1200w" src="user_count_cum.png"/> 
</figure>

<p>Relatedly, there are <strong>8,210,439</strong> unique products in this dataset, where <strong>43.3%</strong> have only one review.</p>
<figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/item_count_cum_hu_8daa25ccc943c402.webp 320w,/2017/01/amazon-spark/item_count_cum_hu_955b99f79f562cd7.webp 768w,/2017/01/amazon-spark/item_count_cum_hu_1ad195a387d28909.webp 1024w,/2017/01/amazon-spark/item_count_cum.png 1200w" src="item_count_cum.png"/> 
</figure>

<p>After removing duplicate ratings, I added a few more features to each rating which may help illustrate how review behavior changed over time: a ranking value indicating the # review that the author of a given review has written (1st review by author, 2nd review by author, etc.), a ranking value indicating the # review that the product of a given review has received (1st review for product, 2nd review for product, etc.), and the month and year the review was made.</p>
<p>The first two added features require a <em>very</em> large amount of processing power, and highlight the convenience of Spark&rsquo;s speed (and the fact that Spark uses all CPU cores by default, while typical R/Python approaches are single-threaded!)</p>
<p>These changes are cached into a Spark DataFrame <code>df_t</code>. If I wanted to determine which Amazon product category receives the best review ratings on average, I can aggregate the data by category, calculate the average rating score for each category, and sort. Thanks to the power of Spark, the data processing for this many-millions-of-records takes seconds.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-r" data-lang="r"><span class="line"><span class="cl"><span class="n">df_agg</span> <span class="o">&lt;-</span> <span class="n">df_t</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">            <span class="nf">group_by</span><span class="p">(</span><span class="n">category</span><span class="p">)</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">            <span class="nf">summarize</span><span class="p">(</span><span class="n">count</span> <span class="o">=</span> <span class="nf">n</span><span class="p">(),</span> <span class="n">avg_rating</span> <span class="o">=</span> <span class="nf">mean</span><span class="p">(</span><span class="n">rating</span><span class="p">))</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">            <span class="nf">arrange</span><span class="p">(</span><span class="nf">desc</span><span class="p">(</span><span class="n">avg_rating</span><span class="p">))</span> <span class="o">%&gt;%</span>
</span></span><span class="line"><span class="cl">            <span class="nf">collect</span><span class="p">()</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/avg_hu_24f1b4ab4339fd26.webp 320w,/2017/01/amazon-spark/avg_hu_699a7e6381f1a38f.webp 768w,/2017/01/amazon-spark/avg.png 962w" src="avg.png"/> 
</figure>

<p>Or, visualized in chart form using <a href="http://ggplot2.org">ggplot2</a>:</p>
<figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/avg_rating_desc_hu_a4ddfa7be2c75fbd.webp 320w,/2017/01/amazon-spark/avg_rating_desc_hu_5e6789cd9495791d.webp 768w,/2017/01/amazon-spark/avg_rating_desc_hu_f1c761a8c71557d9.webp 1024w,/2017/01/amazon-spark/avg_rating_desc.png 1200w" src="avg_rating_desc.png"/> 
</figure>

<p>Digital Music/CD products receive the highest reviews on average, while Video Games and Cell Phones receive the lowest reviews on average, with a <strong>0.77</strong> rating range between them. This does make some intuitive sense; Digital Music and CDs are types of products where you know <em>exactly</em> what you are getting with no chance of a random product defect, while Cell Phones and Accessories can have variable quality from shady third-party sellers (Video Games in particular are also prone to irrational <a href="http://steamed.kotaku.com/steam-games-are-now-even-more-susceptible-to-review-bom-1774940065">review bombing</a> over minor grievances).</p>
<p>We can refine this visualization by splitting each bar into a percentage breakdown of each rating from 1-5. This could be plotted with a pie chart for each category, however a stacked bar chart, scaled to 100%, looks much cleaner.</p>
<figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/category_breakdown_hu_56697490c6e5e18.webp 320w,/2017/01/amazon-spark/category_breakdown_hu_433f387b09546fd8.webp 768w,/2017/01/amazon-spark/category_breakdown_hu_5e8c2aba48f55a50.webp 1024w,/2017/01/amazon-spark/category_breakdown.png 1200w" src="category_breakdown.png"/> 
</figure>

<p>The new visualization does help support the theory above; the top categories have a significantly higher percentage of 4/5-star ratings than the bottom categories, and a much a lower proportion of 1/2/3-star ratings. The inverse holds true for the bottom categories.</p>
<p>How have these breakdowns changed over time? Are there other factors in play?</p>
<h2 id="rating-breakdowns-over-time">Rating Breakdowns Over Time</h2>
<p>Perhaps the advent of the binary Like/Dislike behaviors in social media in the 2000&rsquo;s have translated into a change in behavior for a 5-star review system. Here are the rating breakdowns for reviews written in each month from January 2000 to July 2014:</p>
<figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/time_breakdown_hu_3b40970c67c5dd8a.webp 320w,/2017/01/amazon-spark/time_breakdown_hu_e279eb96257dc056.webp 768w,/2017/01/amazon-spark/time_breakdown_hu_fe56bce22245cdf.webp 1024w,/2017/01/amazon-spark/time_breakdown.png 1200w" src="time_breakdown.png"/> 
</figure>

<p>The voting behavior oscillates very slightly over time with no clear spikes or inflection points, which dashes that theory.</p>
<h2 id="distribution-of-average-scores">Distribution of Average Scores</h2>
<p>We should look at the global averages of Amazon product scores (i.e. what customers see when they buy products), and the users who give the ratings. We would expect the distributions to match, so any deviations would be interesting.</p>
<p>Products on average, when looking at products with atleast 5 ratings, have a <strong>4.16</strong> overall rating.</p>
<figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/item_histogram_hu_b5c0dd55f5e6ccca.webp 320w,/2017/01/amazon-spark/item_histogram_hu_b4be0bc02d2408a0.webp 768w,/2017/01/amazon-spark/item_histogram_hu_398b78930a28f79d.webp 1024w,/2017/01/amazon-spark/item_histogram.png 1200w" src="item_histogram.png"/> 
</figure>

<p>When looking at a similar graph for the overall ratings given by users, (5 ratings minimum), the average rating is slightly higher at <strong>4.20</strong>.</p>
<figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/user_histogram_hu_46fa162c3c0a3bab.webp 320w,/2017/01/amazon-spark/user_histogram_hu_fb8dae1d5d34cedf.webp 768w,/2017/01/amazon-spark/user_histogram_hu_9d7210271d963b43.webp 1024w,/2017/01/amazon-spark/user_histogram.png 1200w" src="user_histogram.png"/> 
</figure>

<p>The primary difference between the two distributions is that there is significantly higher proportion of Amazon customers giving <em>only</em> 5-star reviews. Normalizing and overlaying the two charts clearly highlights that discrepancy.</p>
<figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/user_item_histogram_hu_1b96e01d8d762a1f.webp 320w,/2017/01/amazon-spark/user_item_histogram_hu_c0e6f7c088bdc8c0.webp 768w,/2017/01/amazon-spark/user_item_histogram_hu_ee477c1eaf841ccd.webp 1024w,/2017/01/amazon-spark/user_item_histogram.png 1200w" src="user_item_histogram.png"/> 
</figure>

<h2 id="the-marginal-review">The Marginal Review</h2>
<p>A few posts ago, I discussed how the <a href="http://minimaxir.com/2016/11/first-comment/">first comment on a Reddit post</a> has dramatically more influence than subsequent comments. Does user rating behavior change after making more and more reviews? Is the typical rating behavior different for the first review of a given product?</p>
<p>Here is the ratings breakdown for the <em>n</em>-th Amazon review a user gives:</p>
<figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/user_nth_breakdown_hu_c346f6785b5af381.webp 320w,/2017/01/amazon-spark/user_nth_breakdown_hu_466e6aec3324fc8d.webp 768w,/2017/01/amazon-spark/user_nth_breakdown_hu_7f96a46425d7abb2.webp 1024w,/2017/01/amazon-spark/user_nth_breakdown.png 1200w" src="user_nth_breakdown.png"/> 
</figure>

<p>The first user review has a slightly higher proportion of being a 1-star review than subsequent reviews. Otherwise, the voting behavior is mostly the same overtime, although users have an increased proportion of giving a 4-star review instead of a 5-star review as they get more comfortable.</p>
<p>In contrast, here is the ratings breakdown for the <em>n</em>-th review an Amazon product received:</p>
<figure>

    <img loading="lazy" srcset="/2017/01/amazon-spark/item_nth_breakdown_hu_57c6596aabcca292.webp 320w,/2017/01/amazon-spark/item_nth_breakdown_hu_f4e53aa9efa8dea4.webp 768w,/2017/01/amazon-spark/item_nth_breakdown_hu_ac4b2ab9202340fb.webp 1024w,/2017/01/amazon-spark/item_nth_breakdown.png 1200w" src="item_nth_breakdown.png"/> 
</figure>

<p>The first product review has a slightly higher proportion of being a 5-star review than subsequent reviews. However, after the 10th review, there is <em>zero</em> change in the distribution of ratings, which implies that the marginal rating behavior is independent from the current score after that threshold.</p>
<h2 id="summary">Summary</h2>
<p>Granted, this blog post is more playing with data and less analyzing data. What might be interesting to look into for future technical posts is conditional behavior, such as predicting the rating of a review given the previous ratings on that product/by that user. However, this post shows that while &ldquo;big data&rdquo; may be an inscrutable buzzword nowadays, you don&rsquo;t have to work for a Fortune 500 company to be able to understand it. Even with a data set consisting of 5 simple features, you can extract a large number of insights.</p>
<p>And this post doesn&rsquo;t even look at the text of the Amazon product reviews or the metadata associated with the products! I do have a few ideas lined up there which I won&rsquo;t spoil.</p>
<hr>
<p><em>You can view all the R and ggplot2 code used to visualize the Amazon data in <a href="http://minimaxir.com/notebooks/amazon-spark/">this R Notebook</a>. You can also view the images/data used for this post in <a href="https://github.com/minimaxir/amazon-spark">this GitHub repository</a></em>.</p>
<p><em>You are free to use the data visualizations from this article however you wish, but it would be greatly appreciated if proper attribution is given to this article and/or myself!</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>What Percent of the Top-Voted Comments in Reddit Threads Were Also 1st Comment?</title>
      <link>https://minimaxir.com/2016/11/first-comment/</link>
      <pubDate>Mon, 07 Nov 2016 06:30:00 -0700</pubDate>
      <guid>https://minimaxir.com/2016/11/first-comment/</guid>
      <description>Are commenters &amp;rsquo;late to this thread&amp;rsquo; indeed late?</description>
      <content:encoded><![CDATA[<p><a href="https://www.reddit.com">Reddit</a> threads can be a crowded place. In popular subreddits such as <a href="https://www.reddit.com/r/AskReddit/">/r/AskReddit</a> and <a href="https://www.reddit.com/r/pics/">/r/pics</a>, Reddit submissions can receive hundreds, even <em>thousands</em> of unique comments. Some comments inevitably become lost in the noise. Reddit&rsquo;s <a href="https://redditblog.com/2009/10/15/reddits-new-comment-sorting-system/">ranking algorithm</a> attempts to rectify this by determining comment ranking using both time and community voting; comments in a thread, by default, are ordered based on the <strong>points score</strong> (upvotes - downvotes) the comment receives, subject to a rank decay based on the age of the comment.</p>
<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/reddit_askreddit_hu_42dc1e3a1f9d90f5.webp 320w,/2016/11/first-comment/reddit_askreddit_hu_fc39e2c66d98aeaa.webp 768w,/2016/11/first-comment/reddit_askreddit_hu_f78209b1fdd81424.webp 1024w,/2016/11/first-comment/reddit_askreddit.png 1735w" src="reddit_askreddit.png"/> 
</figure>

<p>In theory, this system should allow comments that posted later in the thread&rsquo;s lifetime to rank much higher temporarily, then Redditors can vote on the new comment; if the new comment is good, it can now rise to the top and therefore the content which would otherwise be buried is now surfaced. Anecdotally, that doesn&rsquo;t be the case with Reddit&rsquo;s modern algorithm; comments made late in the thread appear at the bottom, where they likely will not receive any upvotes (this led to a minor &ldquo;<a href="https://www.google.com/#q=site:reddit.com&#43;%22late&#43;to&#43;this&#43;thread%22">I know I&rsquo;m late to this thread but&hellip;</a>&rdquo; meme).</p>
<p>I, of course, am not satisfied with anecdotes. A month ago, a Redditor asked &ldquo;<a href="https://www.reddit.com/r/TheoryOfReddit/comments/53d5ep/what_percentage_of_the_top_comment_in_threads/">What percentage of the top comment in threads were also the first comment?</a>&rdquo; Why not calculate it <em>exactly</em> using big data?</p>
<h2 id="getting-the-reddit-data">Getting the Reddit Data</h2>
<p><em>You can view all the <a href="https://www.r-project.org">R</a> and <a href="http://ggplot2.org">ggplot2</a> code used to query, analyze, and visualize the Reddit data in <a href="http://minimaxir.com/notebooks/first-comment/">this R Notebook</a>.</em></p>
<p>In order to process a great amount of Reddit data, I turned to <a href="https://cloud.google.com/bigquery/">BigQuery</a>, which now has data for <a href="https://www.reddit.com/r/datasets/comments/590re2/updated_reddit_comments_and_posts_updated_on/">all Reddit comments</a> until September 2016.</p>
<p>For this analysis, I will only look at the <strong>top-level comments</strong> (i.e. comments which are not replies to other comments), since those are the ones most affected by the ordering and submission of new comments. Additionally I will only look at comments within Reddit threads with <strong>atleast 30 top-level comments</strong> to ensure I only look at threads with sufficient discussion and where late posts are more likely to become hidden. It also mirrors the &ldquo;late to this thread&rdquo; meme: can posts be <em>too</em> late?</p>
<p>The queried data will be all comments posted from January 2015 to September 2016: this give a good balance of sample size and foundation around the modern comment ranking algorithms. The total number of Reddit comments analyzed, after filtering on threads with sufficient conversation and limiting the scope to the first 100 comments of a thread scoring within the Top 100, is <strong>n = 86,561,476</strong>.</p>
<p>With clever use of BigQuery window functions, I obtained the aggregate data, counting the number of comments from the filtered Reddit threads at each voting rank and created rank.</p>
<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/data_hu_4d65bf7b71b33d7.webp 320w,/2016/11/first-comment/data.png 485w" src="data.png"/> 
</figure>

<h2 id="visualizing-the-discussion">Visualizing the Discussion</h2>
<p>Filtering on the top-voted comments (<code>score_rank = 1</code>) only, <em>what percent of the top-voted comments in Reddit threads were also 1st Comment?</em></p>
<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/reddit-first-4_hu_f4acfd8a6a24d04b.webp 320w,/2016/11/first-comment/reddit-first-4_hu_3aa2b0ad38f1674f.webp 768w,/2016/11/first-comment/reddit-first-4_hu_e84d5e2030d58585.webp 1024w,/2016/11/first-comment/reddit-first-4.png 1200w" src="reddit-first-4.png"/> 
</figure>

<p>The answer is <strong>17.24%</strong> of all top-voted comments! That&rsquo;s certainly more than what I expected! Additionally, 56% of the top-voted comments were posted within the first 5 comments, and 77% within the first 10 comments. The chart follows a <a href="https://en.wikipedia.org/wiki/Power_law">power-law distribution</a>.</p>
<p>Let&rsquo;s invert it: filtering on only the first comments (<code>created_rank = 1</code>) made in comment threads, <em>what percentage of the 1st Comments in Reddit threads were also the top-voted comment?</em></p>
<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/reddit-first-3_hu_32e5a7361944ad80.webp 320w,/2016/11/first-comment/reddit-first-3_hu_e9d692ee421e53f2.webp 768w,/2016/11/first-comment/reddit-first-3_hu_3b23b0a0ff87cb8b.webp 1024w,/2016/11/first-comment/reddit-first-3.png 1200w" src="reddit-first-3.png"/> 
</figure>

<p>By construction, the answer is the same as before (17.24%), however the followup proportions are slightly different, with the first comment ranking within the Top 5 comments 46% of the time, and within the Top 10 comments 62% of the time.</p>
<p>It may be worth it to visualize both dimensions at the same time using a heatmap, with the created rank on one axis, score rank on the other, and a z-axis representing the number of comments at each rank pairing. We can also add a faint contour line to help visualize clusters of the data. Putting it together:</p>
<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/reddit-first-2_hu_bca6892d8c2637fb.webp 320w,/2016/11/first-comment/reddit-first-2_hu_69edc86dedd2cf27.webp 768w,/2016/11/first-comment/reddit-first-2_hu_5d672628af81568d.webp 1024w,/2016/11/first-comment/reddit-first-2.png 1200w" src="reddit-first-2.png"/> 
</figure>

<p>Woah, most of the values are constrained between the semisquare constrained by the first 5 comments and the top 5 comments! But it&rsquo;s harder to see trends, so let&rsquo;s try applying a logarithmic base-10 scaling on the comment count:</p>
<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/reddit-first-2a_hu_b57a236e353450cf.webp 320w,/2016/11/first-comment/reddit-first-2a_hu_70e1d8aa856624e8.webp 768w,/2016/11/first-comment/reddit-first-2a_hu_def2a7e3ca31b407.webp 1024w,/2016/11/first-comment/reddit-first-2a.png 1200w" src="reddit-first-2a.png"/> 
</figure>

<p>Much better! We can see a grouping of the 5x5 semisquare, but also smaller groupings of a 30x30 shape (this may possibly be due to the 30 comment filter threshold), a faint 60x60 shape, and <em>voids</em> in the upper-left and lower-right corners.</p>
<p>From the 2D heatmap, there appears to be a <strong>positive correlation</strong> between the rank of the comment and the time it was submitted. Ideally, if Reddit&rsquo;s algorithm correctly cycled posts so that each comment gets a fair chance at going viral, then there should be <strong>no correlation</strong> between score rank and time posted.</p>
<h2 id="analysis-by-subreddit">Analysis by Subreddit</h2>
<p>When working with Reddit data, it is always important to facet the analysis by subreddit, as subreddits can have idiosyncratic behaviors which deviate from general Reddit behavior. As noted in the original Reddit thread with the initial question, it is possible that the percentage of first comments becoming top comment is &ldquo;higher in lighter subs (funny, pics, videos) than more serious subs (askscience, history, etc).&rdquo;</p>
<p>I tweaked the BigQuery above to retrieve the same data for each of the Top 100 subreddits (determined by unique commenter count over the same time period). Afterward, via scripting, I created a 1D proportion-of-first-comments-by-score-rank and 2D heatmaps for each subreddit. You can view and download the 1D charts <a href="https://github.com/minimaxir/first-comment/tree/master/img-1d">here</a>, and the 2D heatmaps <a href="https://github.com/minimaxir/first-comment/tree/master/img-2d">here</a>.</p>
<p>For example, here&rsquo;s the chart of first-comment-rankings for <a href="https://www.reddit.com/r/IAmA/">/r/IAmA</a>, one of Reddit&rsquo;s biggest subreddits where normal Redditors can ask celebrities any question they want.</p>
<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/IAmA-1d_hu_573ad937f3f1efb5.webp 320w,/2016/11/first-comment/IAmA-1d_hu_f22cae99be7468ad.webp 768w,/2016/11/first-comment/IAmA-1d_hu_d8a8e93acd87cea7.webp 1024w,/2016/11/first-comment/IAmA-1d.png 1200w" src="IAmA-1d.png"/> 
</figure>

<p>Unlike the all-Reddit chart, the distribution of first-comment proportions is more uniform instead of following a power law. It makes sense in theory; people would likely upvote top-level questions which the original poster replied to, so there should be less of a bias toward the first top-level comment.</p>
<p>What does the 2D heatmap show?</p>
<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/IAmA-2d_hu_fad01356e103cb71.webp 320w,/2016/11/first-comment/IAmA-2d_hu_4b9d791fd03a28a2.webp 768w,/2016/11/first-comment/IAmA-2d_hu_877e7d8fd546142e.webp 1024w,/2016/11/first-comment/IAmA-2d.png 1200w" src="IAmA-2d.png"/> 
</figure>

<p>Damn it.</p>
<p>While the 1D behavior is different, the overall 2D behavior is the same albeit with larger voids (indeed, in the heatmap, you can see at <code>created_rank = 1</code>, the vertical strip doesn&rsquo;t fit the pattern).</p>
<p>It turns out that most /r/IAmA threads have this comment:</p>
<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/automoderator_hu_4da371433ee2b66b.webp 320w,/2016/11/first-comment/automoderator.png 560w" src="automoderator.png"/> 
</figure>

<p>As it&rsquo;s made by a robot, it&rsquo;s always the first comment, and it gets ignored/downvoted in normal circumstances. Other subreddits with the same pattern of 1D irregularities, 2D regularities, and AutoModerator usage are <a href="https://www.reddit.com/r/gameofthrones/">/r/gameofthrones</a>, <a href="https://www.reddit.com/r/photoshopbattles/">/r/photoshopbattles</a>, and <a href="https://www.reddit.com/r/WritingPrompts/">/r/WritingPrompts</a>.</p>
<p>Some subreddits have more uniformity than typical Reddit rank behavior. In <a href="https://www.reddit.com/r/funny/">/r/funny</a>, <a href="https://www.reddit.com/r/leagueoflegends/">/r/leagueoflegends</a>, <a href="https://www.reddit.com/r/pics/">/r/pics</a>, <a href="https://www.reddit.com/r/todayilearned/">/r/todayilearned</a>, and <a href="https://www.reddit.com/r/video/">/r/videos</a> (i.e. many default subreddits), there is no upper-left void (early comments can be poorly ranked) and the bottom-right void is minimized but still present.</p>
<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/funny-2d_hu_73c0fc571865e740.webp 320w,/2016/11/first-comment/funny-2d_hu_b996b9dab212dc84.webp 768w,/2016/11/first-comment/funny-2d_hu_931b173f81201f9e.webp 1024w,/2016/11/first-comment/funny-2d.png 1200w" src="funny-2d.png"/> 
</figure>

<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/leagueoflegends-2d_hu_a9c42eeb50e921e1.webp 320w,/2016/11/first-comment/leagueoflegends-2d_hu_4caa7fc168feac26.webp 768w,/2016/11/first-comment/leagueoflegends-2d_hu_980dd947631bc25b.webp 1024w,/2016/11/first-comment/leagueoflegends-2d.png 1200w" src="leagueoflegends-2d.png"/> 
</figure>

<p>Inversely, there are subreddits where the correlation is obvious. <a href="https://www.reddit.com/r/pcmasterrace/">/r/pcmasterrace</a> and /r/gonewild both exhibit very straight lines, and are subreddits where the comments themselves are not very constructive, so whatever gets posted gets upvoted anyways.</p>
<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/pcmasterrace-2d_hu_db48b685a1bb055d.webp 320w,/2016/11/first-comment/pcmasterrace-2d_hu_19d3a7066357e5f3.webp 768w,/2016/11/first-comment/pcmasterrace-2d_hu_8a658c3fefc2cf5b.webp 1024w,/2016/11/first-comment/pcmasterrace-2d.png 1200w" src="pcmasterrace-2d.png"/> 
</figure>

<figure>

    <img loading="lazy" srcset="/2016/11/first-comment/gonewild-2d_hu_31c8de0d3b20e1ae.webp 320w,/2016/11/first-comment/gonewild-2d_hu_2110b19d8c5df439.webp 768w,/2016/11/first-comment/gonewild-2d_hu_d14d48957f0bfaa4.webp 1024w,/2016/11/first-comment/gonewild-2d.png 1200w" src="gonewild-2d.png"/> 
</figure>

<p>Rushing to say <strong>FIRST!!1!11!</strong> in a comments section of a blog post or forum thread is a meme that long predates Reddit. However, rushing to make the first comment in a Reddit thread may have strategic merit if you want to get your voice heard.</p>
<p>Even in the most optimistic circumstances, comments that are late to a thread have a very, very low probability of becoming one of the top comments. In fairness, it&rsquo;s hard to determine with public Reddit data if tweaking the ranking algorithm such that new comments will always rank at the top initially will actually improve the Reddit user experience as a whole. On the other hand, this behavior presents an opportunity: if there is a <a href="https://en.wikipedia.org/wiki/Long_tail">long tail</a> of Reddit content that is unjustifiably being buried due to lack of attention, then perhaps there is a <em>business opportunity</em> in creating a service to discover and resurface quality comments&hellip;</p>
<hr>
<p><em>You can view all the <a href="https://www.r-project.org">R</a> and <a href="http://ggplot2.org">ggplot2</a> code used to query, analyze, and visualize the Reddit data in <a href="http://minimaxir.com/notebooks/first-comment/">this R Notebook</a>. You can also view the images/data used for this post in <a href="https://github.com/minimaxir/first-comment">this GitHub repository</a></em>.</p>
<p><em>You are free to use the data visualizations from this article however you wish, but it would be greatly appreciated if proper attribution is given to this article and/or myself!</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Visualizing How Developers Rate Their Own Programming Skills</title>
      <link>https://minimaxir.com/2016/07/stack-overflow/</link>
      <pubDate>Thu, 21 Jul 2016 06:30:00 -0700</pubDate>
      <guid>https://minimaxir.com/2016/07/stack-overflow/</guid>
      <description>As it turns out, there is no correlation between programming ability and the frequency of Stack Overflow visits.</description>
      <content:encoded><![CDATA[<p><a href="http://stackoverflow.com">Stack Overflow</a>, the favorite destination for software developers when something breaks for no apparent reason, recently released their <a href="http://stackoverflow.com/research/developer-survey-2016">2016 Stack Overflow Survey Results</a> with responses to the questions of &ldquo;where they work, what they build, and who they are.&rdquo; You can download the released dataset containing all 56,030 cleaned responses <a href="http://stackoverflow.com/research">here</a>.</p>
<p>One variable present in the dataset but surprisingly unaddressed in the official Stack Overflow analysis is the <code>programming_ability</code> field — <em>On a scale of 1-10, how would you rate your programming ability?</em></p>
<p>I took a look at the 46,982 users who identified their programming ability in the survey. On average, developers rate themselves 7.09 / 10. And like most 1-10 rating scales, the distribution of self-assessments is unimodal around 7 and 8, with relatively rare 9&rsquo;s and 10&rsquo;s.</p>
<figure>

    <img loading="lazy" srcset="/2016/07/stack-overflow/so-programming-0_hu_490b45ca6432efea.webp 320w,/2016/07/stack-overflow/so-programming-0_hu_11ab33c947130fec.webp 768w,/2016/07/stack-overflow/so-programming-0_hu_163c89b74f2d4bf.webp 1024w,/2016/07/stack-overflow/so-programming-0.png 1200w" src="so-programming-0.png"/> 
</figure>

<p>We can aggregate the programming ability data by other relevant metrics in the Stack Overflow dataset, such as experience and commit activity, and hopefully find interesting trends.</p>
<h2 id="sanity-checking">Sanity Checking</h2>
<p>I normally dislike working with survey data since there is a high possibility of <a href="https://en.wikipedia.org/wiki/Selection_bias">selection bias</a> among the respondents. In Stack Overflow&rsquo;s case, their marketing of the survey on Facebook and Twitter may cause a high proportion of social-media savvy respondents and discount the insight of developers who are not likely to use those services. For this reason, I will show <a href="https://en.wikipedia.org/wiki/Confidence_interval">confidence intervals</a> whenever possible to reflect the proportionate uncertainty for groupings with insufficient data, and to also account for possibility that a minority of respondents may be dishonest and nudge their programming ability a few points higher than the truth.</p>
<p>Let&rsquo;s compare programming skill to the developer&rsquo;s experience in the field. In the survey, the user could classify their IT / programming experience as a range, from &ldquo;Less than 1 year&rdquo;, &ldquo;1 - 2 years&rdquo;, &ldquo;2 - 5 years&rdquo;, &ldquo;6 - 10 years&rdquo;, and &ldquo;11+ years.&rdquo; Since we would expect a positive correlation between skill and experience, identifying such a positive correlation visually gives a quick indication that the analysis is on the right track.</p>
<p>We can plot the average programming-ability rating for developers which fall into each of those five groups, and a confidence interval for that average. Additionally, we can make a <a href="https://en.wikipedia.org/wiki/Violin_plot">violin plot</a> of each group to give a sense of the underlying distribution of ratings.</p>
<p>Putting it all together:</p>
<figure>

    <img loading="lazy" srcset="/2016/07/stack-overflow/so-programming-5_hu_eba638fd916ec81c.webp 320w,/2016/07/stack-overflow/so-programming-5_hu_474e6cfe1c565acb.webp 768w,/2016/07/stack-overflow/so-programming-5_hu_36228687b3cf1fc8.webp 1024w,/2016/07/stack-overflow/so-programming-5.png 1200w" src="so-programming-5.png"/> 
</figure>

<p>The color dot for each group represents the average rating from the sample which the developers in the group give to themselves. The black error bars on the dot represent a 95% confidence interval for the true value of the average, obtained via <a href="http://minimaxir.com/2015/09/bootstrap-resample/">percentile bootstrap</a> with 10,000 resamples of the dataset with replacement (since there is a large amount of source data, the confidence intervals end up being very narrow in most cases; this is one legitimate advantage of big data).</p>
<p>The violin plot for each group represents the normalized overall distribution of ratings. The narrowness of the per-value ratings reflect the amount of data available for that group: the more data available, the more narrow/precise the kernel smoothing is. Overall flat plots represent a wide selection of self-ratings, while an overall narrow plot represents a more-constrained selection (for the plot above, you can easily see the distribution shift to the right as the experience range increases).</p>
<p>Also, keep in mind that these groupings alone do not imply a <strong>causal relationship</strong> between the two variables. Employing traditional <a href="https://en.wikipedia.org/wiki/Regression_analysis">regression analysis</a> to build a model for predicting programming ability would be tricky: does having more experience cause programming skill to improve, or does having strong innate technical skill cause developers to remain in the industry and grow?</p>
<p>Back to the plot at hand. We can easily confirm that a positive correlation exists between programming activity and experience, with newbie developers rating their skills 5.02 / 10 on average, and extremely experienced developers rating their skills three whole ranks higher at 8.13 / 10. What&rsquo;s also notable is the range of values selected: for developers with <strong>less than 1 years</strong> of experience, the distribution is almost completely flat between 1-7, showing that they are more honest with the self-assessment of their programming skills. Inversely, developers with <strong>11+ years</strong> of experience select 9 and 10 ratings almost as much as 7 and 8 ratings.</p>
<p>It&rsquo;s a good start. We can also compare developer skill to their age, which by construction (older developers have more experience) should have parallel behavior to experience levels.</p>
<figure>

    <img loading="lazy" srcset="/2016/07/stack-overflow/so-programming-4_hu_9a82fa102ab31b89.webp 320w,/2016/07/stack-overflow/so-programming-4_hu_7eaafadcd632b252.webp 768w,/2016/07/stack-overflow/so-programming-4_hu_1101af9046194e99.webp 1024w,/2016/07/stack-overflow/so-programming-4.png 1200w" src="so-programming-4.png"/> 
</figure>

<p>Yes, the plot is indeed similar, with average ratings ranging from 6 to about 8. What&rsquo;s interesting is the behavior for <strong>&gt; 60</strong> vs. <strong>50-59</strong> is that the &gt; 60 age programmers occasionally rate their skills at the low end of the scale, which is why the confidence interval is larger and the average is lower for that group.</p>
<p>Lastly, we can look at the salary the developer is paid (in USD) as a validation of skill. This particular chart will only focus on developers in the United States (n = 13,539), so that the salary follows expected behavior with the specified currency and cost-of-living. In this case, there are many more groups, but that makes the distribution shift more apparent, and more interesting.</p>
<figure>

    <img loading="lazy" srcset="/2016/07/stack-overflow/so-programming-6_hu_7cc9f1fc5ae5d96c.webp 320w,/2016/07/stack-overflow/so-programming-6_hu_f58b25a9402f0a72.webp 768w,/2016/07/stack-overflow/so-programming-6_hu_2e102b83abc9a02d.webp 1024w,/2016/07/stack-overflow/so-programming-6.png 1200w" src="so-programming-6.png"/> 
</figure>

<p>The large amount of &gt;$100k earners in the dataset shows how the Stack Overflow demographic can skew toward Silicon Valley engineers. The $90k—$100k group serves as a convenient inflection point on how the distribution of self-ratings becomes a <a href="http://tvtropes.org/pmwiki/pmwiki.php/Main/FourPointScale">Four Point Scale</a> between 7 and 10 for those who earn more than $100k.</p>
<h2 id="do-better-developers-rate-themselves-better">Do better developers rate themselves better?</h2>
<p>So far, the data is internally consistent. There are a few other developer-relevant statistics are available in the dataset which can easily be aggregated. A good one is the <em>type</em> of employment. For example, do <strong>freelance / contract</strong> developers believe they are better programmers than <strong>full-time</strong> developers?</p>
<figure>

    <img loading="lazy" srcset="/2016/07/stack-overflow/so-programming-7_hu_8f5e6ca147f045e4.webp 320w,/2016/07/stack-overflow/so-programming-7_hu_a12f45d3f2d1f3f8.webp 768w,/2016/07/stack-overflow/so-programming-7_hu_db5f2ea3f76d5e35.webp 1024w,/2016/07/stack-overflow/so-programming-7.png 1200w" src="so-programming-7.png"/> 
</figure>

<p>As it turns out, that guess is indeed the case, albeit it&rsquo;s only a slight difference (7.53 / 10 for <strong>freelance / contract</strong> vs. 7.29 / 10 for <strong>full-time</strong>).</p>
<p>What about repository commit activity by developers? Are developers who commit more better? One could argue that a developer who commits code often is either vigilant with accounting for functional code changes, or polluting the codebase in an attempt to show productivity.</p>
<figure>

    <img loading="lazy" srcset="/2016/07/stack-overflow/so-programming-8_hu_1c91f841623c628.webp 320w,/2016/07/stack-overflow/so-programming-8_hu_91e0d17dc486ebbc.webp 768w,/2016/07/stack-overflow/so-programming-8_hu_a55812a19b16f217.webp 1024w,/2016/07/stack-overflow/so-programming-8.png 1200w" src="so-programming-8.png"/> 
</figure>

<p>Yes, developers who commit lots of code rate themselves better.</p>
<p>Lastly, let&rsquo;s remember that the source of data is Stack Overflow. Are developers who use Stack Overflow as a resource better developers who know how to properly use external references in times of crisis, or are they developers who use it as a crutch to compensate for weak coding skills?</p>
<figure>

    <img loading="lazy" srcset="/2016/07/stack-overflow/so-programming-9_hu_eecd6a2d3b1847ac.webp 320w,/2016/07/stack-overflow/so-programming-9_hu_efde5bf708b6ec62.webp 768w,/2016/07/stack-overflow/so-programming-9_hu_37c013d4469e87bc.webp 1024w,/2016/07/stack-overflow/so-programming-9.png 1200w" src="so-programming-9.png"/> 
</figure>

<p>As it turns out, there is <strong>no correlation between programming ability and and the frequency of Stack Overflow visits</strong>, as the averages and distributions are virtually identical across all groups.</p>
<p>There are many, many other answers available in the dataset; some allow multiple responses and are harder to parse, while others have zero correlation with programming ability as with the Stack Overflow visits, and therefore do not provide much additional insight. Although we cannot establish causal relationships with this methodology, there may be other important insights obtainable from aggregating programming ability data, but the charts presented in this post are a good start.</p>
<hr>
<p><em>As always, the full code used to process the comment data and generate the visualizations is available in <a href="https://github.com/minimaxir/stack-overflow-survey/blob/master/stack_overflow_dev_survey.ipynb">this Jupyter notebook</a>, open-sourced <a href="https://github.com/minimaxir/stack-overflow-survey">on GitHub</a>. The repository also contains a few unused bonus charts!</em></p>
<p><em>You are free to use the charts from this article however you wish, but it would be greatly appreciated if proper attribution is given to this article and/or myself!</em></p>
]]></content:encoded>
    </item>
  </channel>
</rss>
