metachronistic http://swingleydev.com/blog/ Latest metachronistic posts en-us Sun, 13 Mar 2016 08:27:18 -0800 Image similarity analysis: color http://swingleydev.com/blog/p/1995/ <div class="document"> <div class="section" id="introduction"> <h1>Introduction</h1> <p>There are now 777 photos in my <a class="reference external" href="https://swingleydev.com/photolog/">photolog</a>, organized in reverse chronological order (or chronologically if you append <tt class="docutils literal">/asc/</tt> to the url). With that much data, it occurred to me that there ought to be a way to organize these photos by color, similar to the way some people organize their <a class="reference external" href="http://www.slate.com/blogs/the_eye/2014/02/11/arranging_your_books_by_color_is_not_a_moral_failure.html">books</a>. I didn’t find a way of doing that, unfortunately, but I did spend some time experimenting with image similarity analysis using color.</p> <p>The basic idea is to generate histograms (counts of the pixels in the image that fit into pre-defined bins) for red, green and blue color combinations in the image. Once we have these values for each image, we use the chi square distance between the values as a distance metric that is a measure of color similarty between photos.</p> </div> <div class="section" id="code"> <h1>Code</h1> <p>I followed this tutorial <a class="reference external" href="http://www.pyimagesearch.com/2014/01/27/hobbits-and-histograms-a-how-to-guide-to-building-your-first-image-search-engine-in-python/">Building your first image search engine in Python</a> which uses code like this to generate 3D RGB histograms (all the code from this post is on <a class="reference external" href="https://github.com/cswingle/opencv_3d_rgb_histograms">GitHub</a>):</p> <div class="highlight"><pre><span class="kn">import</span> <span class="nn">cv2</span> <span class="k">def</span> <span class="nf">get_histogram</span><span class="p">(</span><span class="n">image</span><span class="p">,</span> <span class="n">bins</span><span class="p">):</span> <span class="sd">&quot;&quot;&quot; calculate a 3d RGB histogram from an image &quot;&quot;&quot;</span> <span class="k">if</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">exists</span><span class="p">(</span><span class="n">image</span><span class="p">):</span> <span class="n">imgarray</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">imread</span><span class="p">(</span><span class="n">image</span><span class="p">)</span> <span class="n">hist</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">calcHist</span><span class="p">([</span><span class="n">imgarray</span><span class="p">],</span> <span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">],</span> <span class="bp">None</span><span class="p">,</span> <span class="p">[</span><span class="n">bins</span><span class="p">,</span> <span class="n">bins</span><span class="p">,</span> <span class="n">bins</span><span class="p">],</span> <span class="p">[</span><span class="mi">0</span><span class="p">,</span> <span class="mi">256</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">256</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">256</span><span class="p">])</span> <span class="n">hist</span> <span class="o">=</span> <span class="n">cv2</span><span class="o">.</span><span class="n">normalize</span><span class="p">(</span><span class="n">hist</span><span class="p">,</span> <span class="n">hist</span><span class="p">)</span> <span class="k">return</span> <span class="n">hist</span><span class="o">.</span><span class="n">flatten</span><span class="p">()</span> <span class="k">else</span><span class="p">:</span> <span class="k">return</span> <span class="bp">None</span> </pre></div> <p>Once you have them, you need to calculate all the pair-wise distances using a function like this:</p> <div class="highlight"><pre><span class="k">def</span> <span class="nf">chi2_distance</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">,</span> <span class="n">eps</span><span class="o">=</span><span class="mf">1e-10</span><span class="p">):</span> <span class="sd">&quot;&quot;&quot; distance between two histograms (a, b) &quot;&quot;&quot;</span> <span class="n">d</span> <span class="o">=</span> <span class="mf">0.5</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">sum</span><span class="p">([((</span><span class="n">x</span> <span class="o">-</span> <span class="n">y</span><span class="p">)</span> <span class="o">**</span> <span class="mi">2</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">x</span> <span class="o">+</span> <span class="n">y</span> <span class="o">+</span> <span class="n">eps</span><span class="p">)</span> <span class="k">for</span> <span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)])</span> <span class="k">return</span> <span class="n">d</span> </pre></div> <p>Getting histogram data using OpenCV in Python is pretty fast. Even with 32 bins, it only took about 45 minutes for all 777 images. Computing the distances between histograms was a lot slower, depending on how the code was written.</p> <p>With 8 bin histograms, a Python script using the function listed above, took just under 15 minutes to calculate each pairwise comparison (see the <a class="reference external" href="https://github.com/cswingle/opencv_3d_rgb_histograms/blob/master/rgb_histogram.py">rgb_histogram.py</a> script).</p> <p>Since the photos are all in a database so they can be displayed on the Internet, I figured a SQL function to calculate the distances would make the most sense. I could use the OpenCV Python code to generate histograms and add them to the database when the photo was inserted, and a SQL function to get the distances.</p> <p>Here’s the function:</p> <div class="highlight"><pre><span class="k">CREATE</span> <span class="k">OR</span> <span class="k">REPLACE</span> <span class="k">FUNCTION</span> <span class="n">chi_square_distance</span><span class="p">(</span><span class="n">a</span> <span class="nb">numeric</span><span class="p">[],</span> <span class="n">b</span> <span class="nb">numeric</span><span class="p">[])</span> <span class="k">RETURNS</span> <span class="nb">numeric</span> <span class="k">AS</span> <span class="err">$</span><span class="n">_$</span> <span class="k">DECLARE</span> <span class="k">sum</span> <span class="nb">numeric</span> <span class="p">:</span><span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="nb">integer</span><span class="p">;</span> <span class="k">BEGIN</span> <span class="k">FOR</span> <span class="n">i</span> <span class="k">IN</span> <span class="mi">1</span> <span class="p">..</span> <span class="n">array_upper</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span> <span class="n">LOOP</span> <span class="n">IF</span> <span class="n">a</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">+</span><span class="n">b</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="k">THEN</span> <span class="k">sum</span> <span class="o">=</span> <span class="k">sum</span> <span class="o">+</span> <span class="p">(</span><span class="n">a</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">-</span><span class="n">b</span><span class="p">[</span><span class="n">i</span><span class="p">])</span><span class="o">^</span><span class="mi">2</span> <span class="o">/</span> <span class="p">(</span><span class="n">a</span><span class="p">[</span><span class="n">i</span><span class="p">]</span><span class="o">+</span><span class="n">b</span><span class="p">[</span><span class="n">i</span><span class="p">]);</span> <span class="k">END</span> <span class="n">IF</span><span class="p">;</span> <span class="k">END</span> <span class="n">LOOP</span><span class="p">;</span> <span class="k">RETURN</span> <span class="k">sum</span><span class="o">/</span><span class="mi">2</span><span class="p">.</span><span class="mi">0</span><span class="p">;</span> <span class="k">END</span><span class="p">;</span> <span class="err">$</span><span class="n">_$</span> <span class="k">LANGUAGE</span> <span class="n">plpgsql</span><span class="p">;</span> </pre></div> <p>Unfortunately, this is incredibly slow. Instead of the 15 minutes the Python script took, it took just under two hours to compute the pairwise distances on the 8 bin histograms.</p> <p>When your interpreted code is slow, the solution is often to re-write compiled code and use that. I found <a class="reference external" href="http://stackoverflow.com/questions/16992339/">some C code</a> on Stack Overflow for writing array functions. The PostgreSQL interface isn’t exactly intuitive, but here’s the gist of the code (<a class="reference external" href="https://github.com/cswingle/opencv_3d_rgb_histograms/blob/master/chi_square_distance.c">full code</a>):</p> <div class="highlight"><pre><span class="cp">#include &lt;postgres.h&gt;</span> <span class="cp">#include &lt;fmgr.h&gt;</span> <span class="cp">#include &lt;utils/array.h&gt;</span> <span class="cp">#include &lt;utils/lsyscache.h&gt;</span> <span class="cm">/* From intarray contrib header */</span> <span class="cp">#define ARRPTR(x) ( (float8 *) ARR_DATA_PTR(x) )</span> <span class="n">PG_MODULE_MAGIC</span><span class="p">;</span> <span class="n">PG_FUNCTION_INFO_V1</span><span class="p">(</span><span class="n">chi_square_distance</span><span class="p">);</span> <span class="n">Datum</span> <span class="nf">chi_square_distance</span><span class="p">(</span><span class="n">PG_FUNCTION_ARGS</span><span class="p">);</span> <span class="n">Datum</span> <span class="nf">chi_square_distance</span><span class="p">(</span><span class="n">PG_FUNCTION_ARGS</span><span class="p">)</span> <span class="p">{</span> <span class="n">ArrayType</span> <span class="o">*</span><span class="n">a</span><span class="p">,</span> <span class="o">*</span><span class="n">b</span><span class="p">;</span> <span class="n">float8</span> <span class="o">*</span><span class="n">da</span><span class="p">,</span> <span class="o">*</span><span class="n">db</span><span class="p">;</span> <span class="n">float8</span> <span class="n">sum</span> <span class="o">=</span> <span class="mf">0.0</span><span class="p">;</span> <span class="kt">int</span> <span class="n">i</span><span class="p">,</span> <span class="n">n</span><span class="p">;</span> <span class="n">da</span> <span class="o">=</span> <span class="n">ARRPTR</span><span class="p">(</span><span class="n">a</span><span class="p">);</span> <span class="n">db</span> <span class="o">=</span> <span class="n">ARRPTR</span><span class="p">(</span><span class="n">b</span><span class="p">);</span> <span class="c1">// Generate the sums.</span> <span class="k">for</span> <span class="p">(</span><span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">n</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="o">*</span><span class="n">da</span> <span class="o">-</span> <span class="o">*</span><span class="n">db</span><span class="p">)</span> <span class="p">{</span> <span class="n">sum</span> <span class="o">=</span> <span class="n">sum</span> <span class="o">+</span> <span class="p">((</span><span class="o">*</span><span class="n">da</span> <span class="o">-</span> <span class="o">*</span><span class="n">db</span><span class="p">)</span> <span class="o">*</span> <span class="p">(</span><span class="o">*</span><span class="n">da</span> <span class="o">-</span> <span class="o">*</span><span class="n">db</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="o">*</span><span class="n">da</span> <span class="o">+</span> <span class="o">*</span><span class="n">db</span><span class="p">));</span> <span class="p">}</span> <span class="n">da</span><span class="o">++</span><span class="p">;</span> <span class="n">db</span><span class="o">++</span><span class="p">;</span> <span class="p">}</span> <span class="n">sum</span> <span class="o">=</span> <span class="n">sum</span> <span class="o">/</span> <span class="mf">2.0</span><span class="p">;</span> <span class="n">PG_RETURN_FLOAT8</span><span class="p">(</span><span class="n">sum</span><span class="p">);</span> <span class="p">}</span> </pre></div> <p>This takes 79 <em>seconds</em> to do all the distance calculates on 8 bin histograms. That kind of improvement is well worth the effort.</p> </div> <div class="section" id="results"> <h1>Results</h1> <p>After all that, the results aren’t as good as I was hoping. For some photos, such as the photos I took while re-raising the bridge across the creek, sorting by the histogram distances does actually identify other photos taken of the same process. For example, these two photos are the closest to each other by 32 bin histogram distance:</p> <div class="figure align-center"> <a class="reference external image-reference" href="//media.swingleydev.com/img/blog/2016/02/bridge.jp://media.swingleydev.com/img/photolog/2014/08/end_of_the_log_raised_to_the_bank_2014-08.jpg"><img alt="//media.swingleydev.com/img/photolog/2014/08/end_of_the_log_raised_to_the_bank_2014-08_600.jpg" class="img-responsive" src="//media.swingleydev.com/img/photolog/2014/08/end_of_the_log_raised_to_the_bank_2014-08_600.jpg" /></a> </div> <br><div class="figure align-center"> <a class="reference external image-reference" href="//media.swingleydev.com/img/photolog/2014/08/moving_heavy_things:_log_edition_2014-08.jpg"><img alt="//media.swingleydev.com/img/photolog/2014/08/moving_heavy_things:_log_edition_2014-08_600.jpg" class="img-responsive" src="//media.swingleydev.com/img/photolog/2014/08/moving_heavy_things:_log_edition_2014-08_600.jpg" /></a> </div> <p>But there are certain images, such as the middle image in the three below that are very close to many of the photos in the database, even though they’re really not all that similar. I think this is because images with a lot of black in them (or white) wind up being similar to each other because of the large areas without color. It may be that performing the same sort of analysis using the HSV color space, but restricting the histogram to regions with high saturation and high value, would yield results that make more sense.</p> <div class="figure align-center"> <a class="reference external image-reference" href="//media.swingleydev.com/img/photolog/2016/01/sunrise_at_abr_2016-01.jpg"><img alt="//media.swingleydev.com/img/photolog/2016/01/sunrise_at_abr_2016-01_600.jpg" class="img-responsive" src="//media.swingleydev.com/img/photolog/2016/01/sunrise_at_abr_2016-01_600.jpg" /></a> </div> <br><div class="figure align-center"> <a class="reference external image-reference" href="//media.swingleydev.com/img/photolog/2013/01/arrival.jpg"><img alt="//media.swingleydev.com/img/photolog/2013/01/arrival_600.jpg" class="img-responsive" src="//media.swingleydev.com/img/photolog/2013/01/arrival_600.jpg" /></a> </div> <br><div class="figure align-center"> <a class="reference external image-reference" href="//media.swingleydev.com/img/photolog/2012/09/chinook_sunrise.jpg"><img alt="//media.swingleydev.com/img/photolog/2012/09/chinook_sunrise_600.jpg" class="img-responsive" src="//media.swingleydev.com/img/photolog/2012/09/chinook_sunrise_600.jpg" /></a> </div> </div> </div> Sun, 13 Mar 2016 08:27:18 -0800 http://swingleydev.com/blog/p/1995/ OpenCV SQL C photolog photos color RGB Getting hops data from YCH Hops website http://swingleydev.com/blog/p/1994/ <div class="document"> <div class="section" id="introduction"> <h1>Introduction</h1> <p>I’ve been <a class="reference external" href="https://swingley.org/brewing/recipe_list.php">brewing beer</a> since the <a class="reference external" href="https://swingley.org/brewing/old_listing.php">early 90s</a>, and since those days the number of hops available to homebrewers has gone from a handfull of varieties (Northern Brewer, Goldings, Fuggle, Willamette, Cluster) to well over a hundred. Whenever I go to my local brewing store I’m bewildered by the large variety of hops, most of which I’ve never heard of. I’m also not all that fond of super-citrusy hops like Cascade or it’s variants, so it is a challenge to find flavor and aroma hops that aren’t citrusy among the several dozen new varieties on display.</p> <p>Most of the hops at the store are <a class="reference external" href="http://ychhops.com/">Yakima Chief – Hop Union</a> branded, and they’ve got a great web site that lists all their varieties and has information about each hop. As convenient as a website can be, I’d rather have the data in a database where I can search and organize it myself. Since the data is all on the website, we can use a web scraping library to grab it and format it however we like.</p> <p>One note: websites change all the time, so whenever the structure of a site changes, the code to grab the data will need to be updated. I originally wrote the code for this post a couple weeks ago, scraping data from the Hop Union web site. This morning, that site had been replaced with an entirely different Yakima Chief – Hop Union site and I had to completely rewrite the code.</p> </div> <div class="section" id="rvest"> <h1><tt class="docutils literal">rvest</tt></h1> <p>I’m using the <a class="reference external" href="https://cran.r-project.org/web/packages/rvest/index.html">rvest</a> package from Hadley Wickham and RStudio to do the work of pulling the data from each page. In the Python world, <a class="reference external" href="http://www.crummy.com/software/BeautifulSoup/">Beautiful Soup</a> would be the library I’d use, but there’s a fair amount of data manipulation needed here and I felt like <a class="reference external" href="https://cran.rstudio.com/web/packages/dplyr/vignettes/introduction.html">dplyr</a> would be easier.</p> </div> <div class="section" id="process"> <h1>Process</h1> <p>First, load all the libraries we need.</p> <div class="highlight"><pre><span class="kn">library</span><span class="p">(</span>rvest<span class="p">)</span> <span class="c1"># scraping data from the web</span> <span class="kn">library</span><span class="p">(</span>dplyr<span class="p">)</span> <span class="c1"># manipulation, filtering, grouping into tables</span> <span class="kn">library</span><span class="p">(</span>stringr<span class="p">)</span> <span class="c1"># string functions</span> <span class="kn">library</span><span class="p">(</span>tidyr<span class="p">)</span> <span class="c1"># creating columns from rows</span> <span class="kn">library</span><span class="p">(</span>RPostgreSQL<span class="p">)</span> <span class="c1"># dump final result to a PostgreSQL database</span> </pre></div> <p>Next, we retrieve the data from the main page that has all the varieties listed on it, and extract the list of links to each hop. In the code below, we read the entire document into a variable, <tt class="docutils literal">hop_varieties</tt> using the <tt class="docutils literal">read_html function</tt>.</p> <p>Once we’ve got the web page, we need to find the HTML nodes that contain links to the page for each individual hop. To do that, we use <tt class="docutils literal">html_nodes()</tt>, passing a <a class="reference external" href="http://www.w3schools.com/cssref/css_selectors.asp">CSS selector</a> to the function. In this case, we’re looking for <tt class="docutils literal">a</tt> tags that have a class of <tt class="docutils literal">card__name</tt>. I figured this out by looking at the raw source code from the page using my web browser’s inspection tools. If you right-click on what looks like a link on the page, one of the options in the pop-up menu is “inspect”, and when you choose that, it will show you the HTML for the element you clicked on. It can take a few tries to find the proper combination of tag, class, attribute or id to uniquely identify the things you want.</p> <p>The YCH site is pretty well organized, so this isn’t too difficult. Once we’ve got the nodes, we extract the links by retrieving the <tt class="docutils literal">href</tt> attribute from each one with <tt class="docutils literal">html_attr()</tt>.</p> <div class="highlight"><pre>hop_varieties <span class="o">&lt;-</span> read_html<span class="p">(</span><span class="s">&quot;http://ychhops.com/varieties&quot;</span><span class="p">)</span> hop_page_links <span class="o">&lt;-</span> hop_varieties <span class="o">%&gt;%</span> html_nodes<span class="p">(</span><span class="s">&quot;a.card__name&quot;</span><span class="p">)</span> <span class="o">%&gt;%</span> html_attr<span class="p">(</span><span class="s">&quot;href&quot;</span><span class="p">)</span> </pre></div> <p>Now we have a list of links to all the varieties on the page. It turns out that they made a mistake when they transitioned to the new site and the links all have the wrong host (<tt class="docutils literal">ych.craft.dev</tt>). We can fix that by applying replacing the host in all the links.</p> <div class="highlight"><pre>fixed_links <span class="o">&lt;-</span> <span class="kp">sapply</span><span class="p">(</span>hop_page_links<span class="p">,</span> FUN<span class="o">=</span><span class="kr">function</span><span class="p">(</span>x<span class="p">)</span> <span class="kp">sub</span><span class="p">(</span><span class="s">&#39;ych.craft.dev&#39;</span><span class="p">,</span> <span class="s">&#39;ychhops.com&#39;</span><span class="p">,</span> x<span class="p">))</span> <span class="o">%&gt;%</span> <span class="kp">as.vector</span><span class="p">()</span> </pre></div> <p>Each page will need to be loaded, the relevant information extracted, and the data formatted into a suitable data structure. I think a data frame is the best format for this, where each row in the data frame represents the data for a single hop and each column is a piece of information from the web page.</p> <p>First we write a function the retrieves the data for a single hop and returns a one-row data frame with that data. Most of the data is pretty simple, with a single value for each hop. Name, description, type of hop, etc. Where it gets more complicated is the each hop can have more than one aroma category, and the statistics for each hop vary from one to the next. What we’ve done here is combine the aromas together into a single string, using the at symbol (<tt class="docutils literal">&#64;</tt>) to separate the parts. Since it’s unlikely that symbol will appear in the data, we can split it back apart later. We do the same thing for the other parameters, creating an <tt class="docutils literal">&#64;</tt>-delimited string for the items, and their values.</p> <div class="highlight"><pre>get_hop_stats <span class="o">&lt;-</span> <span class="kr">function</span><span class="p">(</span>p<span class="p">)</span> <span class="p">{</span> hop_page <span class="o">&lt;-</span> read_html<span class="p">(</span>p<span class="p">)</span> hop_name <span class="o">&lt;-</span> hop_page <span class="o">%&gt;%</span> html_nodes<span class="p">(</span><span class="s">&#39;h1[itemprop=&quot;name&quot;]&#39;</span><span class="p">)</span> <span class="o">%&gt;%</span> html_text<span class="p">()</span> type <span class="o">&lt;-</span> hop_page <span class="o">%&gt;%</span> html_nodes<span class="p">(</span><span class="s">&#39;div.hop-profile__data div[itemprop=&quot;additionalProperty&quot;]&#39;</span><span class="p">)</span> <span class="o">%&gt;%</span> html_text<span class="p">()</span> type <span class="o">&lt;-</span> <span class="p">(</span>str_split<span class="p">(</span>type<span class="p">,</span> <span class="s">&#39; &#39;</span><span class="p">))[[</span><span class="m">1</span><span class="p">]][</span><span class="m">2</span><span class="p">]</span> region <span class="o">&lt;-</span> hop_page <span class="o">%&gt;%</span> html_nodes<span class="p">(</span><span class="s">&#39;div.hop-profile__data h5&#39;</span><span class="p">)</span> <span class="o">%&gt;%</span> html_text<span class="p">()</span> description <span class="o">&lt;-</span> hop_page <span class="o">%&gt;%</span> html_nodes<span class="p">(</span><span class="s">&#39;div.hop-profile__profile p[itemprop=&quot;description&quot;]&#39;</span><span class="p">)</span> <span class="o">%&gt;%</span> html_text<span class="p">()</span> aroma_profiles <span class="o">&lt;-</span> hop_page <span class="o">%&gt;%</span> html_nodes<span class="p">(</span><span class="s">&#39;div.hop-profile__profile h3.headline a[itemprop=&quot;category&quot;]&#39;</span><span class="p">)</span> <span class="o">%&gt;%</span> html_text<span class="p">()</span> aroma_profiles <span class="o">&lt;-</span> <span class="kp">sapply</span><span class="p">(</span>aroma_profiles<span class="p">,</span> FUN<span class="o">=</span><span class="kr">function</span><span class="p">(</span>x<span class="p">)</span> <span class="kp">sub</span><span class="p">(</span><span class="s">&#39;,$&#39;</span><span class="p">,</span> <span class="s">&#39;&#39;</span><span class="p">,</span> x<span class="p">))</span> <span class="o">%&gt;%</span> <span class="kp">as.vector</span><span class="p">()</span> <span class="o">%&gt;%</span> <span class="kp">paste</span><span class="p">(</span>collapse<span class="o">=</span><span class="s">&quot;@&quot;</span><span class="p">)</span> composition_keys <span class="o">&lt;-</span> hop_page <span class="o">%&gt;%</span> html_nodes<span class="p">(</span><span class="s">&#39;div.hop-composition__item&#39;</span><span class="p">)</span> <span class="o">%&gt;%</span> html_text<span class="p">()</span> composition_keys <span class="o">&lt;-</span> <span class="kp">sapply</span><span class="p">(</span>composition_keys<span class="p">,</span> FUN<span class="o">=</span><span class="kr">function</span><span class="p">(</span>x<span class="p">)</span> <span class="kp">tolower</span><span class="p">(</span><span class="kp">gsub</span><span class="p">(</span><span class="s">&#39;[ -]&#39;</span><span class="p">,</span> <span class="s">&#39;_&#39;</span><span class="p">,</span> x<span class="p">)))</span> <span class="o">%&gt;%</span> <span class="kp">as.vector</span><span class="p">()</span> <span class="o">%&gt;%</span> <span class="kp">paste</span><span class="p">(</span>collapse<span class="o">=</span><span class="s">&quot;@&quot;</span><span class="p">)</span> composition_values <span class="o">&lt;-</span> hop_page <span class="o">%&gt;%</span> html_nodes<span class="p">(</span><span class="s">&#39;div.hop-composition__value&#39;</span><span class="p">)</span> <span class="o">%&gt;%</span> html_text<span class="p">()</span> <span class="o">%&gt;%</span> <span class="kp">paste</span><span class="p">(</span>collapse<span class="o">=</span><span class="s">&quot;@&quot;</span><span class="p">)</span> hop <span class="o">&lt;-</span> <span class="kt">data.frame</span><span class="p">(</span><span class="s">&#39;hop&#39;</span><span class="o">=</span>hop_name<span class="p">,</span> <span class="s">&#39;type&#39;</span><span class="o">=</span>type<span class="p">,</span> <span class="s">&#39;region&#39;</span><span class="o">=</span>region<span class="p">,</span> <span class="s">&#39;description&#39;</span><span class="o">=</span>description<span class="p">,</span> <span class="s">&#39;aroma_profiles&#39;</span><span class="o">=</span>aroma_profiles<span class="p">,</span> <span class="s">&#39;composition_keys&#39;</span><span class="o">=</span>composition_keys<span class="p">,</span> <span class="s">&#39;composition_values&#39;</span><span class="o">=</span>composition_values<span class="p">)</span> <span class="p">}</span> </pre></div> <p>With a function that takes a URL as input, and returns a single-row data frame, we use a common idiom in R to combine everything together. The inner-most <tt class="docutils literal">lapply</tt> function will run the function on each of the fixed variety links, and each single-row data frame will then be combined together using <tt class="docutils literal">rbind</tt> within <tt class="docutils literal">do.call</tt>.</p> <div class="highlight"><pre>all_hops <span class="o">&lt;-</span> <span class="kp">do.call</span><span class="p">(</span><span class="kp">rbind</span><span class="p">,</span> <span class="kp">lapply</span><span class="p">(</span>fixed_links<span class="p">,</span> get_hop_stats<span class="p">))</span> <span class="o">%&gt;%</span> tbl_df<span class="p">()</span> <span class="o">%&gt;%</span> arrange<span class="p">(</span>hop<span class="p">)</span> <span class="o">%&gt;%</span> mutate<span class="p">(</span>id<span class="o">=</span>row_number<span class="p">())</span> </pre></div> <p>At this stage we’ve retrieved all the data from the website, but some of it has been encoded in a less that useful format.</p> </div> <div class="section" id="data-tidying"> <h1>Data tidying</h1> <p>To tidy the data, we want to extract only a few of the item / value pairs of data from the data frame, alpha acid, beta acid, co-humulone and total oil. We also need to remove carriage returns from the description and remove the aroma column.</p> <p>We split the keys and values back apart again using the <tt class="docutils literal">&#64;</tt> symbol used earlier to combine them, then use <tt class="docutils literal">unnest</tt> to create duplicate columns with each of the key / value pairs in them. <tt class="docutils literal">spread</tt> pivots these up into columns such that the end result has one row per hop with the relevant composition values as columns in the tidy data set.</p> <div class="highlight"><pre>hops <span class="o">&lt;-</span> all_hops <span class="o">%&gt;%</span> arrange<span class="p">(</span>hop<span class="p">)</span> <span class="o">%&gt;%</span> mutate<span class="p">(</span>description<span class="o">=</span><span class="kp">gsub</span><span class="p">(</span><span class="s">&#39;\\r&#39;</span><span class="p">,</span> <span class="s">&#39;&#39;</span><span class="p">,</span> description<span class="p">),</span> keys<span class="o">=</span>str_split<span class="p">(</span>composition_keys<span class="p">,</span> <span class="s">&quot;@&quot;</span><span class="p">),</span> values<span class="o">=</span>str_split<span class="p">(</span>composition_values<span class="p">,</span> <span class="s">&quot;@&quot;</span><span class="p">))</span> <span class="o">%&gt;%</span> unnest<span class="p">(</span>keys<span class="p">,</span> values<span class="p">)</span> <span class="o">%&gt;%</span> spread<span class="p">(</span>keys<span class="p">,</span> values<span class="p">)</span> <span class="o">%&gt;%</span> select<span class="p">(</span>id<span class="p">,</span> hop<span class="p">,</span> type<span class="p">,</span> region<span class="p">,</span> alpha_acid<span class="p">,</span> beta_acid<span class="p">,</span> co_humulone<span class="p">,</span> total_oil<span class="p">,</span> description<span class="p">)</span> kable<span class="p">(</span>hops <span class="o">%&gt;%</span> select<span class="p">(</span>id<span class="p">,</span> hop<span class="p">,</span> type<span class="p">,</span> region<span class="p">,</span> alpha_acid<span class="p">)</span> <span class="o">%&gt;%</span> <span class="kp">head</span><span class="p">())</span> </pre></div> <table border="1" class="docutils"> <colgroup> <col width="9%" /> <col width="21%" /> <col width="19%" /> <col width="30%" /> <col width="21%" /> </colgroup> <thead valign="bottom"> <tr><th class="head">id</th> <th class="head">hop</th> <th class="head">type</th> <th class="head">region</th> <th class="head">alpha_acid</th> </tr> </thead> <tbody valign="top"> <tr><td>1</td> <td>Admiral</td> <td>Bittering</td> <td>United Kingdom</td> <td>13 - 16%</td> </tr> <tr><td>2</td> <td>Ahtanum™</td> <td>Aroma</td> <td>Pacific Northwest</td> <td>3.5 - 6.5%</td> </tr> <tr><td>3</td> <td>Amarillo®</td> <td>Aroma</td> <td>Pacific Northwest</td> <td>7 - 11%</td> </tr> <tr><td>4</td> <td>Aramis</td> <td>Aroma</td> <td>France</td> <td>7.9 - 8.3%</td> </tr> <tr><td>5</td> <td>Aurora</td> <td>Dual</td> <td>Slovenia</td> <td>7 - 9.5%</td> </tr> <tr><td>6</td> <td>Bitter Gold</td> <td>Dual</td> <td>Pacific Northwest</td> <td>12 - 14.5%</td> </tr> </tbody> </table> <p>For the aromas we have a one to many relationship where each hop has one or more aroma categories associated. We could fully normalize this by created an aroma table and a join table that connects hop and aroma, but this data is simple enough that I just created the join table itself. We’re using the same <tt class="docutils literal">str_split</tt> / <tt class="docutils literal">unnest</tt> method we used before, except that in this case we don't want to turn those into columns, we <em>want</em> a separate row for each hop × aroma combination.</p> <div class="highlight"><pre>hops_aromas <span class="o">&lt;-</span> all_hops <span class="o">%&gt;%</span> select<span class="p">(</span>id<span class="p">,</span> hop<span class="p">,</span> aroma_profiles<span class="p">)</span> <span class="o">%&gt;%</span> mutate<span class="p">(</span>aroma<span class="o">=</span>str_split<span class="p">(</span>aroma_profiles<span class="p">,</span> <span class="s">&quot;@&quot;</span><span class="p">))</span> <span class="o">%&gt;%</span> unnest<span class="p">(</span>aroma<span class="p">)</span> <span class="o">%&gt;%</span> select<span class="p">(</span>id<span class="p">,</span> hop<span class="p">,</span> aroma<span class="p">)</span> </pre></div> </div> <div class="section" id="saving-and-exporting"> <h1>Saving and exporting</h1> <p>Finally, we save the data and export it into a PostgreSQL database.</p> <div class="highlight"><pre><span class="kp">save</span><span class="p">(</span><span class="kt">list</span><span class="o">=</span><span class="kt">c</span><span class="p">(</span><span class="s">&quot;hops&quot;</span><span class="p">,</span> <span class="s">&quot;hops_aromas&quot;</span><span class="p">),</span> file<span class="o">=</span><span class="s">&quot;ych_hops.rdata&quot;</span><span class="p">)</span> beer <span class="o">&lt;-</span> src_postgres<span class="p">(</span>host<span class="o">=</span><span class="s">&quot;dryas.swingleydev.com&quot;</span><span class="p">,</span> dbname<span class="o">=</span><span class="s">&quot;beer&quot;</span><span class="p">,</span> port<span class="o">=</span><span class="m">5434</span><span class="p">,</span> user<span class="o">=</span><span class="s">&quot;cswingle&quot;</span><span class="p">)</span> dbWriteTable<span class="p">(</span>beer<span class="o">$</span>con<span class="p">,</span> <span class="s">&quot;ych_hops&quot;</span><span class="p">,</span> hops <span class="o">%&gt;%</span> <span class="kt">data.frame</span><span class="p">(),</span> row.names<span class="o">=</span><span class="kc">FALSE</span><span class="p">)</span> dbWriteTable<span class="p">(</span>beer<span class="o">$</span>con<span class="p">,</span> <span class="s">&quot;ych_hops_aromas&quot;</span><span class="p">,</span> hops_aromas <span class="o">%&gt;%</span> <span class="kt">data.frame</span><span class="p">(),</span> row.names<span class="o">=</span><span class="kc">FALSE</span><span class="p">)</span> </pre></div> </div> <div class="section" id="usage"> <h1>Usage</h1> <p>I created a view in the database that combines all the aroma categories into a Postgres array type using this query. I also use a pair of regular expressions to convert the alpha acid string into a Postgres numrange.</p> <div class="highlight"><pre><span class="k">CREATE</span> <span class="k">VIEW</span> <span class="n">ych_basic_hop_data</span> <span class="k">AS</span> <span class="k">SELECT</span> <span class="n">ych_hops</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">ych_hops</span><span class="p">.</span><span class="n">hop</span><span class="p">,</span> <span class="n">array_agg</span><span class="p">(</span><span class="n">aroma</span><span class="p">)</span> <span class="k">AS</span> <span class="n">aromas</span><span class="p">,</span> <span class="k">type</span><span class="p">,</span> <span class="n">numrange</span><span class="p">(</span> <span class="n">regexp_replace</span><span class="p">(</span><span class="n">alpha_acid</span><span class="p">,</span> <span class="s1">&#39;([0-9.]+).*&#39;</span><span class="p">,</span> <span class="n">E</span><span class="s1">&#39;\\1&#39;</span><span class="p">)::</span><span class="nb">numeric</span><span class="p">,</span> <span class="n">regexp_replace</span><span class="p">(</span><span class="n">alpha_acid</span><span class="p">,</span> <span class="s1">&#39;.*- ([0-9.]+)%&#39;</span><span class="p">,</span> <span class="n">E</span><span class="s1">&#39;\\1&#39;</span><span class="p">)::</span><span class="nb">numeric</span><span class="p">,</span> <span class="s1">&#39;[]&#39;</span><span class="p">)</span> <span class="k">AS</span> <span class="n">alpha_acid_percent</span><span class="p">,</span> <span class="n">description</span> <span class="k">FROM</span> <span class="n">ych_hops</span> <span class="k">INNER</span> <span class="k">JOIN</span> <span class="n">ych_hops_aromas</span> <span class="k">USING</span><span class="p">(</span><span class="n">id</span><span class="p">)</span> <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">ych_hops</span><span class="p">.</span><span class="n">id</span><span class="p">,</span> <span class="n">ych_hops</span><span class="p">.</span><span class="n">hop</span><span class="p">,</span> <span class="k">type</span><span class="p">,</span> <span class="n">alpha_acid</span><span class="p">,</span> <span class="n">description</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">hop</span><span class="p">;</span> </pre></div> <p>With this, we can, for example, find US aroma hops that are spicy, but without citrus using the <tt class="docutils literal">ANY()</tt> and <tt class="docutils literal">ALL()</tt> array functions.</p> <div class="highlight"><pre><span class="k">SELECT</span> <span class="n">hop</span><span class="p">,</span> <span class="n">region</span><span class="p">,</span> <span class="k">type</span><span class="p">,</span> <span class="n">aromas</span><span class="p">,</span> <span class="n">alpha_acid_percent</span> <span class="k">FROM</span> <span class="n">ych_basic_hop_data</span> <span class="k">WHERE</span> <span class="k">type</span> <span class="o">=</span> <span class="s1">&#39;Aroma&#39;</span> <span class="k">AND</span> <span class="n">region</span> <span class="o">=</span> <span class="s1">&#39;Pacific Northwest&#39;</span> <span class="k">AND</span> <span class="s1">&#39;Spicy&#39;</span> <span class="o">=</span> <span class="k">ANY</span><span class="p">(</span><span class="n">aromas</span><span class="p">)</span> <span class="k">AND</span> <span class="s1">&#39;Citrus&#39;</span> <span class="o">!=</span> <span class="k">ALL</span><span class="p">(</span><span class="n">aromas</span><span class="p">)</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">alpha_acid_percent</span><span class="p">;</span> <span class="n">hop</span> <span class="o">|</span> <span class="n">region</span> <span class="o">|</span> <span class="k">type</span> <span class="o">|</span> <span class="n">aromas</span> <span class="o">|</span> <span class="n">alpha_acid_percent</span> <span class="c1">-----------+-------------------+-------+------------------------------+--------------------</span> <span class="n">Crystal</span> <span class="o">|</span> <span class="n">Pacific</span> <span class="n">Northwest</span> <span class="o">|</span> <span class="n">Aroma</span> <span class="o">|</span> <span class="err">{</span><span class="n">Floral</span><span class="p">,</span><span class="n">Spicy</span><span class="err">}</span> <span class="o">|</span> <span class="p">[</span><span class="mi">3</span><span class="p">,</span><span class="mi">6</span><span class="p">]</span> <span class="n">Hallertau</span> <span class="o">|</span> <span class="n">Pacific</span> <span class="n">Northwest</span> <span class="o">|</span> <span class="n">Aroma</span> <span class="o">|</span> <span class="err">{</span><span class="n">Floral</span><span class="p">,</span><span class="n">Spicy</span><span class="p">,</span><span class="n">Herbal</span><span class="err">}</span> <span class="o">|</span> <span class="p">[</span><span class="mi">3</span><span class="p">.</span><span class="mi">5</span><span class="p">,</span><span class="mi">6</span><span class="p">.</span><span class="mi">5</span><span class="p">]</span> <span class="n">Tettnang</span> <span class="o">|</span> <span class="n">Pacific</span> <span class="n">Northwest</span> <span class="o">|</span> <span class="n">Aroma</span> <span class="o">|</span> <span class="err">{</span><span class="n">Earthy</span><span class="p">,</span><span class="n">Floral</span><span class="p">,</span><span class="n">Spicy</span><span class="p">,</span><span class="n">Herbal</span><span class="err">}</span> <span class="o">|</span> <span class="p">[</span><span class="mi">4</span><span class="p">,</span><span class="mi">6</span><span class="p">]</span> <span class="n">Mt</span><span class="p">.</span> <span class="n">Hood</span> <span class="o">|</span> <span class="n">Pacific</span> <span class="n">Northwest</span> <span class="o">|</span> <span class="n">Aroma</span> <span class="o">|</span> <span class="err">{</span><span class="n">Spicy</span><span class="p">,</span><span class="n">Herbal</span><span class="err">}</span> <span class="o">|</span> <span class="p">[</span><span class="mi">4</span><span class="p">,</span><span class="mi">6</span><span class="p">.</span><span class="mi">5</span><span class="p">]</span> <span class="n">Santiam</span> <span class="o">|</span> <span class="n">Pacific</span> <span class="n">Northwest</span> <span class="o">|</span> <span class="n">Aroma</span> <span class="o">|</span> <span class="err">{</span><span class="n">Floral</span><span class="p">,</span><span class="n">Spicy</span><span class="p">,</span><span class="n">Herbal</span><span class="err">}</span> <span class="o">|</span> <span class="p">[</span><span class="mi">6</span><span class="p">,</span><span class="mi">8</span><span class="p">.</span><span class="mi">5</span><span class="p">]</span> <span class="n">Ultra</span> <span class="o">|</span> <span class="n">Pacific</span> <span class="n">Northwest</span> <span class="o">|</span> <span class="n">Aroma</span> <span class="o">|</span> <span class="err">{</span><span class="n">Floral</span><span class="p">,</span><span class="n">Spicy</span><span class="err">}</span> <span class="o">|</span> <span class="p">[</span><span class="mi">9</span><span class="p">.</span><span class="mi">2</span><span class="p">,</span><span class="mi">9</span><span class="p">.</span><span class="mi">7</span><span class="p">]</span> </pre></div> </div> <div class="section" id="code"> <h1>Code</h1> <p>The RMarkdown version of this post, including the code can be downloaded from GitHub:</p> <p><a class="reference external" href="https://github.com/cswingle/ych_hops_scraper">https://github.com/cswingle/ych_hops_scraper</a></p> </div> </div> Sun, 28 Feb 2016 09:22:50 -0900 http://swingleydev.com/blog/p/1994/ brewing R rvest web scraping PostgreSQL hops Winter Flooding on Goldstream Creek http://swingleydev.com/blog/p/1993/ <div class="document"> <p>Since the middle of 2010 we’ve been monitoring the level of Goldstream Creek for the <a class="reference external" href="http://water.weather.gov/ahps2/hydrograph.php?gage=gsca2&amp;wfo=pafg">National Weather Service</a> by measuring the distance from the top of our bridge to the surface of the water or ice. In 2012 the <a class="reference external" href="https://swingleydev.com/blog/p/1972/">Creek flooded</a> and washed the bridge downstream. We eventually <a class="reference external" href="https://swingleydev.com/photolog/search/bridge/asc/">raised</a> the bridge logs back up onto the banks and resumed our measurements.</p> <p>This winter the Creek had been relatively quiet, with the level hovering around eight feet below the bridge. But last Friday, we awoke to more than four feet of water over the ice, and since then it's continued to rise. This morning’s reading had the ice only 3.17 feet below the surface of the bridge.</p> <div class="figure align-center"> <a class="reference external image-reference" href="//media.swingleydev.com/img/blog/2016/02/bridge.jpg"><img alt="//media.swingleydev.com/img/blog/2016/02/bridge_1024.jpg" class="img-responsive" src="//media.swingleydev.com/img/blog/2016/02/bridge_1024.jpg" /></a> <p class="caption">Overflow within a few feet of the bridge</p> </div> <p>Water also entered the far side of the slough, and is making it’s way around the loop, melting the snow covering the old surface. Even as the main channel stops rising and freezes, water moves closer to the dog yard from the slough.</p> <div class="figure align-center"> <a class="reference external image-reference" href="//media.swingleydev.com/img/blog/2016/02/slough.jpg"><img alt="//media.swingleydev.com/img/blog/2016/02/slough_1024.jpg" class="img-responsive" src="//media.swingleydev.com/img/blog/2016/02/slough_1024.jpg" /></a> <p class="caption">Water entering the slough</p> </div> <p>One of my longer commutes to work involves riding east on the Goldstream Valley trails, crossing the Creek by Ballaine Road, then riding back toward the house on the north side of the Creek. From there, I can cross Goldstream Creek again where the trail at the end of Miller Hill Road and the Miller Hill Extension trail meet, and ride the trails the rest of the way to work. That crossing is also covered with several feet of water and ice.</p> <div class="figure align-center"> <a class="reference external image-reference" href="//media.swingleydev.com/img/blog/2016/02/mh_crossing.jpg"><img alt="//media.swingleydev.com/img/blog/2016/02/mh_crossing_1024.jpg" class="img-responsive" src="//media.swingleydev.com/img/blog/2016/02/mh_crossing_1024.jpg" /></a> <p class="caption">Trail crossing at the end of Miller Hill</p> </div> <p>Yesterday one of my neighbors sent email with the subject line, “Are we doomed?,” so I took a look at the heigh data from past years. The plot below shows the height of the Creek, as measured from the surface of the bridge (click on the plot to view or download a PDF, R code used to generate the plot appears at the bottom of this post).</p> <p>The orange region is the region where the Creek is flowing; between my reporting of 0% ice in spring and 100% ice-covered in fall. The data gap in July 2014 was due to the flood washing the bridge downstream. Because the bridge isn’t in the same location, the height measurements before and after the flood aren’t completely comparable, but I don’t have the data for the difference in elevation between the old and new bridge locations, so this is the best we’ve got.</p> <div class="figure align-center"> <a class="reference external image-reference" href="//media.swingleydev.com/img/blog/2016/02/creek_heights_2010-2016_by_year.pdf"><img alt="//media.swingleydev.com/img/blog/2016/02/creek_heights_2010-2016_by_year.svgz" class="img-responsive" src="//media.swingleydev.com/img/blog/2016/02/creek_heights_2010-2016_by_year.svgz" /></a> <p class="caption">Creek heights by year</p> </div> <p>The light blue line across all the plots shows the current height of the Creek (3.17 feet) for all years of data. 2012 is probably the closest year to our current situation where the Creek rose to around five feet below the bridge in early January. But really nothing is completely comparable to the situation we’re in right now. Breakup won’t come for another two or three months, and in most years, the Creek rises several feet between February and breakup.</p> <p>Time will tell, of course, but here’s why I’m not too worried about it. There’s another bridge crossing several miles downstream, and last Friday there was no water on the surface, and the Creek was easily ten feet below the banks. That means that there is a lot of space within the banks of the Creek downstream that can absorb the melting water as breakup happens. I also think that there is a lot of liquid water trapped beneath the ice on the surface in our neighborhood and that water is likely to slowly drain out downstream, leaving a lot of empty space below the surface ice that can accommodate further overflow as the winter progresses. In past years of walking on the Creek I’ve come across huge areas where the top layer of ice dropped as much as six feet when the water underneath drained away. I’m hoping that this happens here, with a lot of the subsurface water draining downstream.</p> <p>The Creek is always reminding us of how little we really understand what’s going on and how even a small amount of flowing water can become a huge force when that water accumulates more rapidly than the Creek can handle it. Never a dull moment!</p> <div class="section" id="code"> <h1>Code</h1> <div class="highlight"><pre><span class="kn">library</span><span class="p">(</span>readr<span class="p">)</span> <span class="kn">library</span><span class="p">(</span>dplyr<span class="p">)</span> <span class="kn">library</span><span class="p">(</span>tidyr<span class="p">)</span> <span class="kn">library</span><span class="p">(</span>lubridate<span class="p">)</span> <span class="kn">library</span><span class="p">(</span>ggplot2<span class="p">)</span> <span class="kn">library</span><span class="p">(</span>scales<span class="p">)</span> wxcoder <span class="o">&lt;-</span> read_csv<span class="p">(</span><span class="s">&quot;data/wxcoder.csv&quot;</span><span class="p">,</span> na<span class="o">=</span><span class="kt">c</span><span class="p">(</span><span class="s">&quot;-9999&quot;</span><span class="p">))</span> feb_2016_incomplete <span class="o">&lt;-</span> read_csv<span class="p">(</span><span class="s">&quot;data/2016_02_incomplete.csv&quot;</span><span class="p">,</span> na<span class="o">=</span><span class="kt">c</span><span class="p">(</span><span class="s">&quot;-9999&quot;</span><span class="p">))</span> wxcoder <span class="o">&lt;-</span> <span class="kp">rbind</span><span class="p">(</span>wxcoder<span class="p">,</span> feb_2016_incomplete<span class="p">)</span> wxcoder <span class="o">&lt;-</span> wxcoder <span class="o">%&gt;%</span> transmute<span class="p">(</span>dte<span class="o">=</span><span class="kp">as.Date</span><span class="p">(</span>ymd<span class="p">(</span>DATE<span class="p">)),</span> tmin_f<span class="o">=</span>TN<span class="p">,</span> tmax_f<span class="o">=</span>TX<span class="p">,</span> tobs_f<span class="o">=</span>TA<span class="p">,</span> tavg_f<span class="o">=</span><span class="p">(</span>tmin_f<span class="o">+</span>tmax_f<span class="p">)</span><span class="o">/</span><span class="m">2.0</span><span class="p">,</span> prcp_in<span class="o">=</span><span class="kp">ifelse</span><span class="p">(</span>PP<span class="o">==</span><span class="s">&#39;T&#39;</span><span class="p">,</span> <span class="m">0.005</span><span class="p">,</span> <span class="kp">as.numeric</span><span class="p">(</span>PP<span class="p">)),</span> snow_in<span class="o">=</span><span class="kp">ifelse</span><span class="p">(</span>SF<span class="o">==</span><span class="s">&#39;T&#39;</span><span class="p">,</span> <span class="m">0.05</span><span class="p">,</span> <span class="kp">as.numeric</span><span class="p">(</span>SF<span class="p">)),</span> snwd_in<span class="o">=</span>SD<span class="p">,</span> below_bridge_ft<span class="o">=</span>HG<span class="p">,</span> ice_cover_pct<span class="o">=</span>IC<span class="p">)</span> creek <span class="o">&lt;-</span> wxcoder <span class="o">%&gt;%</span> filter<span class="p">(</span>dte<span class="o">&gt;</span><span class="kp">as.Date</span><span class="p">(</span>ymd<span class="p">(</span><span class="s">&quot;2010-05-27&quot;</span><span class="p">)))</span> creek_w_year <span class="o">&lt;-</span> creek <span class="o">%&gt;%</span> mutate<span class="p">(</span>year<span class="o">=</span>year<span class="p">(</span>dte<span class="p">),</span> doy<span class="o">=</span>yday<span class="p">(</span>dte<span class="p">))</span> ice_free_date <span class="o">&lt;-</span> creek_w_year <span class="o">%&gt;%</span> group_by<span class="p">(</span>year<span class="p">)</span> <span class="o">%&gt;%</span> filter<span class="p">(</span>ice_cover_pct<span class="o">==</span><span class="m">0</span><span class="p">)</span> <span class="o">%&gt;%</span> summarize<span class="p">(</span>ice_free_dte<span class="o">=</span><span class="kp">min</span><span class="p">(</span>dte<span class="p">),</span> ice_free_doy<span class="o">=</span><span class="kp">min</span><span class="p">(</span>doy<span class="p">))</span> ice_covered_date <span class="o">&lt;-</span> creek_w_year <span class="o">%&gt;%</span> group_by<span class="p">(</span>year<span class="p">)</span> <span class="o">%&gt;%</span> filter<span class="p">(</span>ice_cover_pct<span class="o">==</span><span class="m">100</span><span class="p">,</span> doy<span class="o">&gt;</span><span class="m">182</span><span class="p">)</span> <span class="o">%&gt;%</span> summarize<span class="p">(</span>ice_covered_dte<span class="o">=</span><span class="kp">min</span><span class="p">(</span>dte<span class="p">),</span> ice_covered_doy<span class="o">=</span><span class="kp">min</span><span class="p">(</span>doy<span class="p">))</span> flowing_creek_dates <span class="o">&lt;-</span> ice_free_date <span class="o">%&gt;%</span> inner_join<span class="p">(</span>ice_covered_date<span class="p">,</span> by<span class="o">=</span><span class="s">&quot;year&quot;</span><span class="p">)</span> <span class="o">%&gt;%</span> mutate<span class="p">(</span>ymin<span class="o">=</span><span class="kc">Inf</span><span class="p">,</span> ymax<span class="o">=-</span><span class="kc">Inf</span><span class="p">)</span> latest_obs <span class="o">&lt;-</span> creek_w_year <span class="o">%&gt;%</span> mutate<span class="p">(</span>rank<span class="o">=</span><span class="kp">rank</span><span class="p">(</span>desc<span class="p">(</span>dte<span class="p">)))</span> <span class="o">%&gt;%</span> filter<span class="p">(</span>rank<span class="o">==</span><span class="m">1</span><span class="p">)</span> current_height_df <span class="o">&lt;-</span> <span class="kt">data.frame</span><span class="p">(</span> year<span class="o">=</span><span class="kt">c</span><span class="p">(</span><span class="m">2011</span><span class="p">,</span> <span class="m">2012</span><span class="p">,</span> <span class="m">2013</span><span class="p">,</span> <span class="m">2014</span><span class="p">,</span> <span class="m">2015</span><span class="p">,</span> <span class="m">2016</span><span class="p">),</span> below_bridge_ft<span class="o">=</span>latest_obs<span class="o">$</span>below_bridge_ft<span class="p">)</span> q <span class="o">&lt;-</span> ggplot<span class="p">(</span>data<span class="o">=</span>creek_w_year <span class="o">%&gt;%</span> filter<span class="p">(</span>year<span class="o">&gt;</span><span class="m">2010</span><span class="p">),</span> aes<span class="p">(</span>x<span class="o">=</span>doy<span class="p">,</span> y<span class="o">=</span>below_bridge_ft<span class="p">))</span> <span class="o">+</span> theme_bw<span class="p">()</span> <span class="o">+</span> geom_rect<span class="p">(</span>data<span class="o">=</span>flowing_creek_dates <span class="o">%&gt;%</span> filter<span class="p">(</span>year<span class="o">&gt;</span><span class="m">2010</span><span class="p">),</span> aes<span class="p">(</span>xmin<span class="o">=</span>ice_free_doy<span class="p">,</span> xmax<span class="o">=</span>ice_covered_doy<span class="p">,</span> ymin<span class="o">=</span>ymin<span class="p">,</span> ymax<span class="o">=</span>ymax<span class="p">),</span> fill<span class="o">=</span><span class="s">&quot;darkorange&quot;</span><span class="p">,</span> alpha<span class="o">=</span><span class="m">0.4</span><span class="p">,</span> inherit.aes<span class="o">=</span><span class="kc">FALSE</span><span class="p">)</span> <span class="o">+</span> <span class="c1"># geom_point(size=0.5) +</span> geom_line<span class="p">()</span> <span class="o">+</span> geom_hline<span class="p">(</span>data<span class="o">=</span>current_height_df<span class="p">,</span> aes<span class="p">(</span>yintercept<span class="o">=</span>below_bridge_ft<span class="p">),</span> colour<span class="o">=</span><span class="s">&quot;darkcyan&quot;</span><span class="p">,</span> alpha<span class="o">=</span><span class="m">0.4</span><span class="p">)</span> <span class="o">+</span> scale_x_continuous<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;&quot;</span><span class="p">,</span> breaks<span class="o">=</span><span class="kt">c</span><span class="p">(</span><span class="m">1</span><span class="p">,</span><span class="m">32</span><span class="p">,</span><span class="m">60</span><span class="p">,</span><span class="m">91</span><span class="p">,</span> <span class="m">121</span><span class="p">,</span><span class="m">152</span><span class="p">,</span><span class="m">182</span><span class="p">,</span><span class="m">213</span><span class="p">,</span> <span class="m">244</span><span class="p">,</span><span class="m">274</span><span class="p">,</span><span class="m">305</span><span class="p">,</span><span class="m">335</span><span class="p">,</span> <span class="m">365</span><span class="p">),</span> labels<span class="o">=</span><span class="kt">c</span><span class="p">(</span><span class="s">&quot;Jan&quot;</span><span class="p">,</span> <span class="s">&quot;Feb&quot;</span><span class="p">,</span> <span class="s">&quot;Mar&quot;</span><span class="p">,</span> <span class="s">&quot;Apr&quot;</span><span class="p">,</span> <span class="s">&quot;May&quot;</span><span class="p">,</span> <span class="s">&quot;Jun&quot;</span><span class="p">,</span> <span class="s">&quot;Jul&quot;</span><span class="p">,</span> <span class="s">&quot;Aug&quot;</span><span class="p">,</span> <span class="s">&quot;Sep&quot;</span><span class="p">,</span> <span class="s">&quot;Oct&quot;</span><span class="p">,</span> <span class="s">&quot;Nov&quot;</span><span class="p">,</span> <span class="s">&quot;Dec&quot;</span><span class="p">,</span> <span class="s">&quot;Jan&quot;</span><span class="p">))</span> <span class="o">+</span> scale_y_reverse<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;Creek height, feet below bridge&quot;</span><span class="p">,</span> breaks<span class="o">=</span>pretty_breaks<span class="p">(</span>n<span class="o">=</span><span class="m">5</span><span class="p">))</span> <span class="o">+</span> facet_wrap<span class="p">(</span><span class="o">~</span> year<span class="p">,</span> ncol<span class="o">=</span><span class="m">1</span><span class="p">)</span> width <span class="o">&lt;-</span> <span class="m">16</span> height <span class="o">&lt;-</span> <span class="m">16</span> rescale <span class="o">&lt;-</span> <span class="m">0.75</span> pdf<span class="p">(</span><span class="s">&quot;creek_heights_2010-2016_by_year.pdf&quot;</span><span class="p">,</span> width<span class="o">=</span>width<span class="o">*</span>rescale<span class="p">,</span> height<span class="o">=</span>height<span class="o">*</span>rescale<span class="p">)</span> <span class="kp">print</span><span class="p">(</span><span class="kp">q</span><span class="p">)</span> dev.off<span class="p">()</span> svg<span class="p">(</span><span class="s">&quot;creek_heights_2010-2016_by_year.svg&quot;</span><span class="p">,</span> width<span class="o">=</span>width<span class="o">*</span>rescale<span class="p">,</span> height<span class="o">=</span>height<span class="o">*</span>rescale<span class="p">)</span> <span class="kp">print</span><span class="p">(</span><span class="kp">q</span><span class="p">)</span> dev.off<span class="p">()</span> </pre></div> </div> </div> Sun, 14 Feb 2016 10:02:50 -0900 http://swingleydev.com/blog/p/1993/ Goldstream Creek flood weather R bridge FitBit Charge HR Accuracy During Exercise http://swingleydev.com/blog/p/1992/ <div class="document"> <div class="section" id="introduction"> <h1>Introduction</h1> <p>This week a class action lawsuit was filed against FitBit, claiming that their heart rate fitness trackers don’t perform as advertised, specifically during exercise. I’ve been wearing a FitBit Charge HR since October, and also wear a Scosche Rhythm+ heart rate monitor whenever I exercise, so I have a lot of data that I can use to assess the legitimacy of the lawsuit. The data and RMarkdown file used to produce this post is available from <a class="reference external" href="https://github.com/cswingle/fitbit_hr">GitHub</a>.</p> </div> <div class="section" id="the-data"> <h1>The Data</h1> <p>Heart rate data from the Rhythm+ is collected by the RunKeeper app on my phone, and after transferring the data to RunKeeper’s website, I can download GPX files containing all the GPS and heart rate data for each exercise. Data from the Charge HR is a little harder to get, but with the proper tools and permission from FitBit, you can get what they call “intraday” data. I use the <tt class="docutils literal">fitbit</tt> Python library and a set of routines I wrote (also available from <a class="reference external" href="https://github.com/cswingle/fb2psql">GitHub</a>) to pull this data.</p> <p>The data includes 116 activities, mostly from commuting to and from work by bicycle, fat bike, or on skis. The first step in the process is to pair the two data sets, but since the exact moment when each sensor recorded data won’t match, I grouped both sets of data into 15-second intervals, and calculated the mean heart rate for each sensor withing that 15-second window. The result looks like this:</p> <div class="highlight"><pre><span class="kp">load</span><span class="p">(</span><span class="s">&quot;matched_hr_data.rdata&quot;</span><span class="p">)</span> kable<span class="p">(</span><span class="kp">head</span><span class="p">(</span>matched_hr_data<span class="p">))</span> </pre></div> <table border="1" class="tosf docutils"> <colgroup> <col width="22%" /> <col width="13%" /> <col width="13%" /> <col width="13%" /> <col width="13%" /> <col width="14%" /> <col width="14%" /> </colgroup> <thead valign="bottom"> <tr><th class="head">dt_rounded</th> <th class="head">track_id</th> <th class="head">type</th> <th class="head">min_temp</th> <th class="head">max_temp</th> <th class="head">rhythm_hr</th> <th class="head">fitbit_hr</th> </tr> </thead> <tbody valign="top"> <tr><td>2015-10-06 06:18:00</td> <td>3399</td> <td>Bicycling</td> <td>17.8</td> <td>35</td> <td>103.00000</td> <td>100.6</td> </tr> <tr><td>2015-10-06 06:19:00</td> <td>3399</td> <td>Bicycling</td> <td>17.8</td> <td>35</td> <td>101.50000</td> <td>94.1</td> </tr> <tr><td>2015-10-06 06:20:00</td> <td>3399</td> <td>Bicycling</td> <td>17.8</td> <td>35</td> <td>88.57143</td> <td>97.1</td> </tr> <tr><td>2015-10-06 06:21:00</td> <td>3399</td> <td>Bicycling</td> <td>17.8</td> <td>35</td> <td>115.14286</td> <td>104.2</td> </tr> <tr><td>2015-10-06 06:22:00</td> <td>3399</td> <td>Bicycling</td> <td>17.8</td> <td>35</td> <td>133.62500</td> <td>107.4</td> </tr> <tr><td>2015-10-06 06:23:00</td> <td>3399</td> <td>Bicycling</td> <td>17.8</td> <td>35</td> <td>137.00000</td> <td>113.3</td> </tr> <tr><td>...</td> <td>...</td> <td>...</td> <td>...</td> <td>...</td> <td>...</td> <td>...</td> </tr> </tbody> </table> <p>Let’s take a quick look at a few of these activities. The squiggly lines show the heart rate data from the two sensors, and the horizontal lines show the average heart rate for the activity. In both cases, the FitBit Charge HR is shown in red and the Scosche Rhythm+ is blue.</p> <div class="highlight"><pre><span class="kn">library</span><span class="p">(</span>dplyr<span class="p">)</span> <span class="kn">library</span><span class="p">(</span>tidyr<span class="p">)</span> <span class="kn">library</span><span class="p">(</span>ggplot2<span class="p">)</span> <span class="kn">library</span><span class="p">(</span>lubridate<span class="p">)</span> <span class="kn">library</span><span class="p">(</span>scales<span class="p">)</span> heart_rate <span class="o">&lt;-</span> matched_hr_data <span class="o">%&gt;%</span> transmute<span class="p">(</span>dt<span class="o">=</span>dt_rounded<span class="p">,</span> track_id<span class="o">=</span>track_id<span class="p">,</span> title<span class="o">=</span><span class="kp">paste</span><span class="p">(</span><span class="kp">strftime</span><span class="p">(</span><span class="kp">as.Date</span><span class="p">(</span>dt_rounded<span class="p">,</span> tz<span class="o">=</span><span class="s">&quot;US/Alaska&quot;</span><span class="p">),</span> <span class="s">&quot;%b-%d&quot;</span><span class="p">),</span> type<span class="p">),</span> fitbit<span class="o">=</span>fitbit_hr<span class="p">,</span> rhythm<span class="o">=</span>rhythm_hr<span class="p">)</span> <span class="o">%&gt;%</span> gather<span class="p">(</span>key<span class="o">=</span>sensor<span class="p">,</span> value<span class="o">=</span>hr<span class="p">,</span> fitbit<span class="o">:</span>rhythm<span class="p">)</span> <span class="o">%&gt;%</span> filter<span class="p">(</span>track_id <span class="o">%in%</span> <span class="kt">c</span><span class="p">(</span><span class="m">3587</span><span class="p">,</span> <span class="m">3459</span><span class="p">,</span> <span class="m">3437</span><span class="p">,</span> <span class="m">3503</span><span class="p">))</span> activity_means <span class="o">&lt;-</span> heart_rate <span class="o">%&gt;%</span> group_by<span class="p">(</span>track_id<span class="p">,</span> sensor<span class="p">)</span> <span class="o">%&gt;%</span> summarize<span class="p">(</span>hr<span class="o">=</span><span class="kp">mean</span><span class="p">(</span>hr<span class="p">))</span> facet_labels <span class="o">&lt;-</span> heart_rate <span class="o">%&gt;%</span> select<span class="p">(</span>track_id<span class="p">,</span> title<span class="p">)</span> <span class="o">%&gt;%</span> distinct<span class="p">()</span> hr_labeller <span class="o">&lt;-</span> <span class="kr">function</span><span class="p">(</span>values<span class="p">)</span> <span class="p">{</span> <span class="kp">lapply</span><span class="p">(</span>values<span class="p">,</span> FUN<span class="o">=</span><span class="kr">function</span><span class="p">(</span>x<span class="p">)</span> <span class="p">(</span>facet_labels <span class="o">%&gt;%</span> filter<span class="p">(</span>track_id<span class="o">==</span>x<span class="p">))</span><span class="o">$</span>title<span class="p">)</span> <span class="p">}</span> r <span class="o">&lt;-</span> ggplot<span class="p">(</span>data<span class="o">=</span>heart_rate<span class="p">,</span> aes<span class="p">(</span>x<span class="o">=</span>dt<span class="p">,</span> y<span class="o">=</span>hr<span class="p">,</span> colour<span class="o">=</span>sensor<span class="p">))</span> <span class="o">+</span> geom_hline<span class="p">(</span>data<span class="o">=</span>activity_means<span class="p">,</span> aes<span class="p">(</span>yintercept<span class="o">=</span>hr<span class="p">,</span> colour<span class="o">=</span>sensor<span class="p">),</span> alpha<span class="o">=</span><span class="m">0.5</span><span class="p">)</span> <span class="o">+</span> geom_line<span class="p">()</span> <span class="o">+</span> theme_bw<span class="p">()</span> <span class="o">+</span> scale_color_brewer<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;Sensor&quot;</span><span class="p">,</span> breaks<span class="o">=</span><span class="kt">c</span><span class="p">(</span><span class="s">&quot;fitbit&quot;</span><span class="p">,</span> <span class="s">&quot;rhythm&quot;</span><span class="p">),</span> labels<span class="o">=</span><span class="kt">c</span><span class="p">(</span><span class="s">&quot;FitBit Charge HR&quot;</span><span class="p">,</span> <span class="s">&quot;Scosche Rhythm+&quot;</span><span class="p">),</span> palette<span class="o">=</span><span class="s">&quot;Set1&quot;</span><span class="p">)</span> <span class="o">+</span> scale_x_datetime<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;Time&quot;</span><span class="p">)</span> <span class="o">+</span> theme<span class="p">(</span>axis.text.x<span class="o">=</span>element_blank<span class="p">(),</span> axis.ticks.x<span class="o">=</span>element_blank<span class="p">())</span> <span class="o">+</span> scale_y_continuous<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;Heart rate (bpm)&quot;</span><span class="p">)</span> <span class="o">+</span> facet_wrap<span class="p">(</span><span class="o">~</span>track_id<span class="p">,</span> scales<span class="o">=</span><span class="s">&quot;free&quot;</span><span class="p">,</span> labeller<span class="o">=</span>hr_labeller<span class="p">,</span> ncol<span class="o">=</span><span class="m">1</span><span class="p">)</span> <span class="o">+</span> ggtitle<span class="p">(</span><span class="s">&quot;Comparison between heart rate monitors during a single activity&quot;</span><span class="p">)</span> <span class="kp">print</span><span class="p">(</span>r<span class="p">)</span> </pre></div> <div class="figure"> <object class="img-responsive" data="//media.swingleydev.com/img/blog/2016/01/two_good_two_bad.svg" type="image/svg+xml"> //media.swingleydev.com/img/blog/2016/01/two_good_two_bad.svg</object> </div> <p>You can see that for each activity type, one of the plots shows data where the two heart rate monitors track well, and one where they don’t. And when they don’t agree the FitBit is <em>wildly</em> inaccurate. When I initially got my FitBit I experimented with different positions on my arm for the device but it didn’t seem to matter, so I settled on the advice from FitBit, which is to place the band slightly higher on the wrist (two to three fingers from the wrist bone) than in normal use.</p> <p>One other pattern is evident from the two plots where the FitBit does poorly: the heart rate readings are always much lower than reality.</p> <p>A scatterplot of all the data, plotting the FitBit heart rate against the Rhythm+ shows the overall pattern.</p> <div class="highlight"><pre>q <span class="o">&lt;-</span> ggplot<span class="p">(</span>data<span class="o">=</span>matched_hr_data<span class="p">,</span> aes<span class="p">(</span>x<span class="o">=</span>rhythm_hr<span class="p">,</span> y<span class="o">=</span>fitbit_hr<span class="p">,</span> colour<span class="o">=</span>type<span class="p">))</span> <span class="o">+</span> geom_abline<span class="p">(</span>intercept<span class="o">=</span><span class="m">0</span><span class="p">,</span> slope<span class="o">=</span><span class="m">1</span><span class="p">)</span> <span class="o">+</span> geom_point<span class="p">(</span>alpha<span class="o">=</span><span class="m">0.25</span><span class="p">,</span> size<span class="o">=</span><span class="m">1</span><span class="p">)</span> <span class="o">+</span> geom_smooth<span class="p">(</span>method<span class="o">=</span><span class="s">&quot;lm&quot;</span><span class="p">,</span> inherit.aes<span class="o">=</span><span class="kc">FALSE</span><span class="p">,</span> aes<span class="p">(</span>x<span class="o">=</span>rhythm_hr<span class="p">,</span> y<span class="o">=</span>fitbit_hr<span class="p">))</span> <span class="o">+</span> theme_bw<span class="p">()</span> <span class="o">+</span> scale_x_continuous<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;Scosche Rhythm+ heart rate (bpm)&quot;</span><span class="p">)</span> <span class="o">+</span> scale_y_continuous<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;FitBit Charge HR heart rate (bpm)&quot;</span><span class="p">)</span> <span class="o">+</span> scale_colour_brewer<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;Activity type&quot;</span><span class="p">,</span> palette<span class="o">=</span><span class="s">&quot;Set1&quot;</span><span class="p">)</span> <span class="o">+</span> ggtitle<span class="p">(</span><span class="s">&quot;Comparison between heart rate monitors during exercise&quot;</span><span class="p">)</span> <span class="kp">print</span><span class="p">(</span><span class="kp">q</span><span class="p">)</span> </pre></div> <div class="figure"> <object class="img-responsive" data="//media.swingleydev.com/img/blog/2016/01/fitbit_vs_rhythm_scatterplot.svg" type="image/svg+xml"> //media.swingleydev.com/img/blog/2016/01/fitbit_vs_rhythm_scatterplot.svg</object> </div> <p>If the FitBit device were always accurate, the points would all be distributed along the 1:1 line, which is the diagonal black line under the point cloud. The blue diagonal line shows the actual linear relationship between the FitBit and Rhythm+ data. What’s curious is that the two lines cross near 100 bpm, which means that the FitBit is underestimating heart rate when my heart is beating fast, but <em>over</em>estimates it when it’s not.</p> <p>The color of the points indicate the type of activity for each point, and you can see that most of the lower heart rate points (and overestimation by the FitBit) come from hiking activities. Is it the type of activity that triggers over- or underestimation of heart rate from the FitBit, or is is just that all the lower heart rate activities tend to be hiking?</p> <p>Another way to look at the same data is to calculate the difference between the Rhythm+ and FitBit and plot those anomalies against the actual (Rhythm+) heart rate.</p> <div class="highlight"><pre>anomaly_by_hr <span class="o">&lt;-</span> matched_hr_data <span class="o">%&gt;%</span> mutate<span class="p">(</span>anomaly<span class="o">=</span>fitbit_hr<span class="o">-</span>rhythm_hr<span class="p">)</span> <span class="o">%&gt;%</span> select<span class="p">(</span>rhythm_hr<span class="p">,</span> anomaly<span class="p">,</span> type<span class="p">)</span> q <span class="o">&lt;-</span> ggplot<span class="p">(</span>data<span class="o">=</span>anomaly_by_hr<span class="p">,</span> aes<span class="p">(</span>x<span class="o">=</span>rhythm_hr<span class="p">,</span> y<span class="o">=</span>anomaly<span class="p">,</span> colour<span class="o">=</span>type<span class="p">))</span> <span class="o">+</span> geom_abline<span class="p">(</span>intercept<span class="o">=</span><span class="m">0</span><span class="p">,</span> slope<span class="o">=</span><span class="m">0</span><span class="p">,</span> alpha<span class="o">=</span><span class="m">0.5</span><span class="p">)</span> <span class="o">+</span> geom_point<span class="p">(</span>alpha<span class="o">=</span><span class="m">0.25</span><span class="p">,</span> size<span class="o">=</span><span class="m">1</span><span class="p">)</span> <span class="o">+</span> theme_bw<span class="p">()</span> <span class="o">+</span> scale_x_continuous<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;Scosche Rhythm+ heart rate (bpm)&quot;</span><span class="p">,</span> breaks<span class="o">=</span>pretty_breaks<span class="p">(</span>n<span class="o">=</span><span class="m">10</span><span class="p">))</span> <span class="o">+</span> scale_y_continuous<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;Difference between FitBit Charge HR and Rhythm+ (bpm)&quot;</span><span class="p">,</span> breaks<span class="o">=</span>pretty_breaks<span class="p">(</span>n<span class="o">=</span><span class="m">10</span><span class="p">))</span> <span class="o">+</span> scale_colour_brewer<span class="p">(</span>palette<span class="o">=</span><span class="s">&quot;Set1&quot;</span><span class="p">)</span> <span class="kp">print</span><span class="p">(</span><span class="kp">q</span><span class="p">)</span> </pre></div> <div class="figure"> <object class="img-responsive" data="//media.swingleydev.com/img/blog/2016/01/fitbit_anomaly_vs_rhythm_scatterplot.svg" type="image/svg+xml"> //media.swingleydev.com/img/blog/2016/01/fitbit_anomaly_vs_rhythm_scatterplot.svg</object> </div> <p>In this case, all the points should be distributed along the zero line (no difference between FitBit and Rhythm+). We can see a large bluish (fat biking) cloud around the line between 130 and 165 bpm (indicating good results from the FitBit), but the rest of the points appear to be well distributed along a diagonal line which crosses the zero line around 90 bpm. It’s another way of saying the same thing: at lower heart rates the FitBit tends to overestimate heart rate, and as my heart rate rises above 90 beats per minute, the FitBit underestimates heart rate to a greater and greater extent.</p> </div> <div class="section" id="students-t-test-and-results"> <h1>Student’s t-test and results</h1> <p>A Student’s t-test can be used effectively with paired data like this to judge whether the two data sets are statistically different from one another. This routine runs a paired t-test on the data from each activity, testing the null hypothesis that the FitBit heart rate values are the same as the Rhythm+ values. I’m tacking on significance labels typical in analyses like these where one asterisk indicates the results would only happen by chance 5% of the time, two asterisks mean random data would only show this pattern 1% of the time, and three asterisks mean there’s less than a 0.1% chance of this happening by chance.</p> <p>One note: There are 116 activities, so at the 0.05 significance level, we would expect five or six of them to be different just by chance. That doesn’t mean our overall conclusions are suspect, but you do have to keep the number of tests in mind when looking at the results.</p> <div class="highlight"><pre>t_tests <span class="o">&lt;-</span> matched_hr_data <span class="o">%&gt;%</span> group_by<span class="p">(</span>track_id<span class="p">,</span> type<span class="p">,</span> min_temp<span class="p">,</span> max_temp<span class="p">)</span> <span class="o">%&gt;%</span> summarize_each<span class="p">(</span>funs<span class="p">(</span>p_value<span class="o">=</span>t.test<span class="p">(</span><span class="m">.</span><span class="p">,</span> rhythm_hr<span class="p">,</span> paired<span class="o">=</span><span class="kc">TRUE</span><span class="p">)</span><span class="o">$</span>p.value<span class="p">,</span> anomaly<span class="o">=</span>t.test<span class="p">(</span><span class="m">.</span><span class="p">,</span> rhythm_hr<span class="p">,</span> paired<span class="o">=</span><span class="kc">TRUE</span><span class="p">)</span><span class="o">$</span>estimate<span class="p">[</span><span class="m">1</span><span class="p">]),</span> vars<span class="o">=</span>fitbit_hr<span class="p">)</span> <span class="o">%&gt;%</span> ungroup<span class="p">()</span> <span class="o">%&gt;%</span> mutate<span class="p">(</span>sig<span class="o">=</span><span class="kp">ifelse</span><span class="p">(</span>p_value<span class="o">&lt;</span><span class="m">0.001</span><span class="p">,</span> <span class="s">&#39;***&#39;</span><span class="p">,</span> <span class="kp">ifelse</span><span class="p">(</span>p_value<span class="o">&lt;</span><span class="m">0.01</span><span class="p">,</span> <span class="s">&#39;**&#39;</span><span class="p">,</span> <span class="kp">ifelse</span><span class="p">(</span>p_value<span class="o">&lt;</span><span class="m">0.05</span><span class="p">,</span> <span class="s">&#39;*&#39;</span><span class="p">,</span> <span class="s">&#39;&#39;</span><span class="p">))))</span> <span class="o">%&gt;%</span> select<span class="p">(</span>track_id<span class="p">,</span> type<span class="p">,</span> min_temp<span class="p">,</span> max_temp<span class="p">,</span> anomaly<span class="p">,</span> p_value<span class="p">,</span> sig<span class="p">)</span> kable<span class="p">(</span><span class="kp">head</span><span class="p">(</span>t_tests<span class="p">))</span> </pre></div> <table border="1" class="tosf docutils"> <colgroup> <col width="14%" /> <col width="14%" /> <col width="14%" /> <col width="14%" /> <col width="14%" /> <col width="14%" /> <col width="14%" /> </colgroup> <thead valign="bottom"> <tr><th class="head">track_id</th> <th class="head">type</th> <th class="head">min_temp</th> <th class="head">max_temp</th> <th class="head">anomaly</th> <th class="head">p_value</th> <th class="head">sig</th> </tr> </thead> <tbody valign="top"> <tr><td>3399</td> <td>Bicycling</td> <td>17.8</td> <td>35.0</td> <td>-27.766016</td> <td>0.0000000</td> <td>***</td> </tr> <tr><td>3401</td> <td>Bicycling</td> <td>37.0</td> <td>46.6</td> <td>-12.464228</td> <td>0.0010650</td> <td>**</td> </tr> <tr><td>3403</td> <td>Bicycling</td> <td>15.8</td> <td>38.0</td> <td>-4.714672</td> <td>0.0000120</td> <td>***</td> </tr> <tr><td>3405</td> <td>Bicycling</td> <td>42.4</td> <td>44.3</td> <td>-1.652476</td> <td>0.1059867</td> <td>&nbsp;</td> </tr> <tr><td>3407</td> <td>Bicycling</td> <td>23.3</td> <td>40.0</td> <td>-7.142151</td> <td>0.0000377</td> <td>***</td> </tr> <tr><td>3409</td> <td>Bicycling</td> <td>44.6</td> <td>45.5</td> <td>-3.441501</td> <td>0.0439596</td> <td>*</td> </tr> <tr><td>...</td> <td>...</td> <td>...</td> <td>...</td> <td>...</td> <td>...</td> <td>...</td> </tr> </tbody> </table> <p>It’s easier to interpret the results summarized by activity type:</p> <div class="highlight"><pre>t_test_summary <span class="o">&lt;-</span> t_tests <span class="o">%&gt;%</span> mutate<span class="p">(</span>different<span class="o">=</span><span class="kp">grepl</span><span class="p">(</span><span class="s">&#39;\\*&#39;</span><span class="p">,</span> sig<span class="p">))</span> <span class="o">%&gt;%</span> select<span class="p">(</span>type<span class="p">,</span> anomaly<span class="p">,</span> different<span class="p">)</span> <span class="o">%&gt;%</span> group_by<span class="p">(</span>type<span class="p">,</span> different<span class="p">)</span> <span class="o">%&gt;%</span> summarize<span class="p">(</span>n<span class="o">=</span>n<span class="p">(),</span> mean_anomaly<span class="o">=</span><span class="kp">mean</span><span class="p">(</span>anomaly<span class="p">))</span> kable<span class="p">(</span>t_test_summary<span class="p">)</span> </pre></div> <table border="1" class="tosf docutils"> <colgroup> <col width="28%" /> <col width="26%" /> <col width="12%" /> <col width="34%" /> </colgroup> <thead valign="bottom"> <tr><th class="head">type</th> <th class="head">different</th> <th class="head">n</th> <th class="head">mean_anomaly</th> </tr> </thead> <tbody valign="top"> <tr><td>Bicycling</td> <td>FALSE</td> <td>2</td> <td>-1.169444</td> </tr> <tr><td>Bicycling</td> <td>TRUE</td> <td>26</td> <td>-20.847136</td> </tr> <tr><td>Fat Biking</td> <td>FALSE</td> <td>15</td> <td>-1.128833</td> </tr> <tr><td>Fat Biking</td> <td>TRUE</td> <td>58</td> <td>-14.958953</td> </tr> <tr><td>Hiking</td> <td>FALSE</td> <td>2</td> <td>-0.691730</td> </tr> <tr><td>Hiking</td> <td>TRUE</td> <td>8</td> <td>10.947165</td> </tr> <tr><td>Skiing</td> <td>TRUE</td> <td>5</td> <td>-28.710941</td> </tr> </tbody> </table> <p>What this shows is that the FitBit underestimated heart rate by an average of 21 beats per minute in 26 of 28 (93%) bicycling trips, underestimated heart rate by an average of 15 bpm in 58 of 73 (79%) fat biking trips, overestimate heart rate by an average of 11 bpm in 80% of hiking trips, and always drastically underestimated my heart rate while skiing.</p> <p>For all the data:</p> <div class="highlight"><pre>t.test<span class="p">(</span>matched_hr_data<span class="o">$</span>fitbit_hr<span class="p">,</span> matched_hr_data<span class="o">$</span>rhythm_hr<span class="p">,</span> paired<span class="o">=</span><span class="kc">TRUE</span><span class="p">)</span> </pre></div> <pre class="literal-block"> ## ## Paired t-test ## ## data: matched_hr_data$fitbit_hr and matched_hr_data$rhythm_hr ## t = -38.6232, df = 4461, p-value &lt; 2.2e-16 ## alternative hypothesis: true difference in means is not equal to 0 ## 95 percent confidence interval: ## -13.02931 -11.77048 ## sample estimates: ## mean of the differences ## -12.3999 </pre> <p>Indeed, in aggregate, the FitBit does a poor job at estimating heart rate during exercise.</p> </div> <div class="section" id="conclusion"> <h1>Conclusion</h1> <p>Based on my data of more than 100 activities, I’d say the lawsuit has some merit. I only get accurate heart rate readings during exercise from my FitBit Charge HR about 16% of the time, and the error in the heart rate estimates appears to get worse as my actual heart rate increases. The advertising for these devices gives you the impression that they’re designed for high intensity exercise (showing people being very active, running, bicycling, etc.), but their performance during these activities is pretty poor.</p> <p>All that said, I knew this going in when I bought my FitBit, so I’m not hugely disappointed. There are plenty of other benefits to monitoring the data from these devices (including non-exercise heart rate), and it isn’t a major inconvenience for me to strap on a more accurate heart rate monitor for those times when it actually matters.</p> </div> </div> Sat, 09 Jan 2016 15:00:45 -0900 http://swingleydev.com/blog/p/1992/ heart rate exercise FitBit Temperature effects on fat biking, part 2 http://swingleydev.com/blog/p/1991/ <div class="document"> <div class="section" id="introduction"> <h1>Introduction</h1> <p>While riding to work this morning I figured out a way to disentangle the effects of trail quality and physical conditioning (both of which improve over the season) from temperature, which also tends to increase throughout the season. As you recall in my <a class="reference external" href="https://swingleydev.com/blog/p/1990/">previous post</a>, I found that days into the season (winter day of year) and minimum temperature were both negatively related with fat bike energy consumption. But because those variables are also related to each other, we can’t make statements about them individually.</p> <p>But what if we look at pairs of trips that are within two days of each other and look at the difference in temperature between those trips and the difference in energy consumption? We’ll only pair trips going the same direction (to or from work), and we’ll restrict the pairings to two days or less. That eliminates seasonality from the data because we’re always comparing two trips from the same few days.</p> </div> <div class="section" id="data"> <h1>Data</h1> <p>For this analysis, I’m using SQL to filter the data because I’m better at window functions and filtering in SQL than R. Here’s the code to grab the data from the database. (The CSV file and RMarkdown script is on my <a class="reference external" href="https://github.com/cswingle/fat_biking">GitHub repo</a> for this analysis). The trick here is to categorize trips as being to work (“north”) or from work (“south”) and then include this field in the partition statement of the window function so I’m only getting the next trip that matches direction.</p> <div class="highlight"><pre><span class="kn">library</span><span class="p">(</span>dplyr<span class="p">)</span> <span class="kn">library</span><span class="p">(</span>ggplot2<span class="p">)</span> <span class="kn">library</span><span class="p">(</span>scales<span class="p">)</span> exercise_db <span class="o">&lt;-</span> src_postgres<span class="p">(</span>host<span class="o">=</span><span class="s">&quot;example.com&quot;</span><span class="p">,</span> dbname<span class="o">=</span><span class="s">&quot;exercise_data&quot;</span><span class="p">)</span> diffs <span class="o">&lt;-</span> tbl<span class="p">(</span>exercise_db<span class="p">,</span> build_sql<span class="p">(</span> <span class="s">&quot;WITH all_to_work AS (</span> <span class="s"> SELECT *,</span> <span class="s"> CASE WHEN extract(hour from start_time) &lt; 11</span> <span class="s"> THEN &#39;north&#39; ELSE &#39;south&#39; END AS direction</span> <span class="s"> FROM track_stats</span> <span class="s"> WHERE type = &#39;Fat Biking&#39;</span> <span class="s"> AND miles between 4 and 4.3</span> <span class="s"> ), with_next AS (</span> <span class="s"> SELECT track_id, start_time, direction, kcal, miles, min_temp,</span> <span class="s"> lead(direction) OVER w AS next_direction,</span> <span class="s"> lead(start_time) OVER w AS next_start_time,</span> <span class="s"> lead(kcal) OVER w AS next_kcal,</span> <span class="s"> lead(miles) OVER w AS next_miles,</span> <span class="s"> lead(min_temp) OVER w AS next_min_temp</span> <span class="s"> FROM all_to_work</span> <span class="s"> WINDOW w AS (PARTITION BY direction ORDER BY start_time)</span> <span class="s"> )</span> <span class="s"> SELECT start_time, next_start_time, direction,</span> <span class="s"> min_temp, next_min_temp,</span> <span class="s"> kcal / miles AS kcal_per_mile,</span> <span class="s"> next_kcal / next_miles as next_kcal_per_mile,</span> <span class="s"> next_min_temp - min_temp AS temp_diff,</span> <span class="s"> (next_kcal / next_miles) - (kcal / miles) AS kcal_per_mile_diff</span> <span class="s"> FROM with_next</span> <span class="s"> WHERE next_start_time - start_time &lt; &#39;60 hours&#39;</span> <span class="s"> ORDER BY start_time&quot;</span><span class="p">))</span> <span class="o">%&gt;%</span> collect<span class="p">()</span> write.csv<span class="p">(</span>diffs<span class="p">,</span> file<span class="o">=</span><span class="s">&quot;fat_biking_trip_diffs.csv&quot;</span><span class="p">,</span> quote<span class="o">=</span><span class="kc">TRUE</span><span class="p">,</span> row.names<span class="o">=</span><span class="kc">FALSE</span><span class="p">)</span> kable<span class="p">(</span><span class="kp">head</span><span class="p">(</span>diffs<span class="p">))</span> </pre></div> <table border="1" class="docutils"> <colgroup> <col width="29%" /> <col width="29%" /> <col width="17%" /> <col width="24%" /> </colgroup> <thead valign="bottom"> <tr><th class="head">start time</th> <th class="head">next start time</th> <th class="head">temp diff</th> <th class="head">kcal / mile diff</th> </tr> </thead> <tbody valign="top"> <tr><td>2013-12-03 06:21:49</td> <td>2013-12-05 06:31:54</td> <td>3.0</td> <td>-13.843866</td> </tr> <tr><td>2013-12-03 15:41:48</td> <td>2013-12-05 15:24:10</td> <td>3.7</td> <td>-8.823329</td> </tr> <tr><td>2013-12-05 06:31:54</td> <td>2013-12-06 06:39:04</td> <td>23.4</td> <td>-22.510564</td> </tr> <tr><td>2013-12-05 15:24:10</td> <td>2013-12-06 16:38:31</td> <td>13.6</td> <td>-5.505662</td> </tr> <tr><td>2013-12-09 06:41:07</td> <td>2013-12-11 06:15:32</td> <td>-27.7</td> <td>-10.227048</td> </tr> <tr><td>2013-12-09 13:44:59</td> <td>2013-12-11 16:00:11</td> <td>-25.4</td> <td>-1.034789</td> </tr> </tbody> </table> <p>Out of a total of 123 trips, 70 took place within 2 days of each other. We still don’t have a measure of trail quality, so pairs where the trail is smooth and hard one day and covered with fresh snow the next won’t be particularly good data points.</p> <p>Let’s look at a plot of the data.</p> <div class="highlight"><pre>s <span class="o">=</span> ggplot<span class="p">(</span>data<span class="o">=</span>diffs<span class="p">,</span> aes<span class="p">(</span>x<span class="o">=</span>temp_diff<span class="p">,</span> y<span class="o">=</span>kcal_per_mile_diff<span class="p">))</span> <span class="o">+</span> geom_point<span class="p">()</span> <span class="o">+</span> geom_smooth<span class="p">(</span>method<span class="o">=</span><span class="s">&quot;lm&quot;</span><span class="p">,</span> se<span class="o">=</span><span class="kc">FALSE</span><span class="p">)</span> <span class="o">+</span> scale_x_continuous<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;Temperature difference between paired trips (degrees F)&quot;</span><span class="p">,</span> breaks<span class="o">=</span>pretty_breaks<span class="p">(</span>n<span class="o">=</span><span class="m">10</span><span class="p">))</span> <span class="o">+</span> scale_y_continuous<span class="p">(</span>name<span class="o">=</span><span class="s">&quot;Energy consumption difference (kcal / mile)&quot;</span><span class="p">,</span> breaks<span class="o">=</span>pretty_breaks<span class="p">(</span>n<span class="o">=</span><span class="m">10</span><span class="p">))</span> <span class="o">+</span> theme_bw<span class="p">()</span> <span class="o">+</span> ggtitle<span class="p">(</span><span class="s">&quot;Paired fat bike trips to and from work within 2 days of each other&quot;</span><span class="p">)</span> <span class="kp">print</span><span class="p">(</span>s<span class="p">)</span> </pre></div> <div class="figure"> <object class="img-responsive" data="//media.swingleydev.com/img/blog/2015/12/paired_trip_plot.svg" type="image/svg+xml"> //media.swingleydev.com/img/blog/2015/12/paired_trip_plot.svg</object> </div> <p>This shows that when the temperature difference between two paired trips is negative (the second trip is colder than the first), additional energy is required for the second (colder) trip. This matches the pattern we saw in my earlier post where minimum temperature and winter day of year were negatively associated with energy consumption. But because we’ve used differences to remove seasonal effects, we can actually determine how large of an effect temperature has.</p> <p>There are quite a few outliers here. Those that are in the region with very little difference in temperature are likey due to snowfall changing the trail conditions from one trip to the next. I’m not sure why there is so much scatter among the points on the left side of the graph, but I don’t see any particular pattern among those points that might explain the higher than normal variation, and we don’t see the same variation in the points with a large positive difference in temperature, so I think this is just normal variation in the data not explained by temperature.</p> <div class="section" id="results"> <h2>Results</h2> <p>Here’s the linear regression results for this data.</p> <div class="highlight"><pre><span class="kp">summary</span><span class="p">(</span>lm<span class="p">(</span>data<span class="o">=</span>diffs<span class="p">,</span> kcal_per_mile_diff <span class="o">~</span> temp_diff<span class="p">))</span> </pre></div> <pre class="literal-block"> ## ## Call: ## lm(formula = kcal_per_mile_diff ~ temp_diff, data = diffs) ## ## Residuals: ## Min 1Q Median 3Q Max ## -40.839 -4.584 -0.169 3.740 47.063 ## ## Coefficients: ## Estimate Std. Error t value Pr(&gt;|t|) ## (Intercept) -2.1696 1.5253 -1.422 0.159 ## temp_diff -0.7778 0.1434 -5.424 8.37e-07 *** ## --- ## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1 ## ## Residual standard error: 12.76 on 68 degrees of freedom ## Multiple R-squared: 0.302, Adjusted R-squared: 0.2917 ## F-statistic: 29.42 on 1 and 68 DF, p-value: 8.367e-07 </pre> <p>The model and coefficient are both highly signficant, and as we might expect, the intercept in the model is not significantly different from zero (if there wasn’t a difference in temperature between two trips there shouldn’t be a difference in energy consumption either, on average). Temperature alone explains 30% of the variation in energy consumption, and the coefficient tells us the scale of the effect: each degree drop in temperature results in an increase in energy consumption of 0.78 kcalories per mile. So for a 4 mile commute like mine, the difference between a trip at 10°F vs −20°F is an additional 93 kilocalories (30 × 0.7778 × 4 = 93.34) on the colder trip. That might not sound like much in the context of the calories in food (93 kilocalories is about the energy in a large orange or a light beer), but my average energy consumption across all fat bike trips to and from work is 377 kilocalories so 93 represents a large portion of the total.</p> </div> </div> </div> Mon, 21 Dec 2015 16:58:53 -0900 http://swingleydev.com/blog/p/1991/ exercise fat bike R temperature