<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Vikram Oberoi</title>
    <link>https://vikramoberoi.com/</link>
    <description>Recent content on Vikram Oberoi</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-us</language>
    <managingEditor>hello@vikramoberoi.com (Vikram Oberoi)</managingEditor>
    <webMaster>hello@vikramoberoi.com (Vikram Oberoi)</webMaster>
    <copyright>© 2025 Vikram Oberoi</copyright>
    <lastBuildDate>Fri, 26 Jul 2024 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://vikramoberoi.com/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Can you figure out where people&#39;s talking points come from with LLMs?</title>
      <link>https://vikramoberoi.com/posts/can-you-figure-out-where-peoples-talking-points-com-from-with-llms/</link>
      <pubDate>Fri, 26 Jul 2024 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/can-you-figure-out-where-peoples-talking-points-com-from-with-llms/</guid>
      <description>🥳&#xA;The data and code for this post is available under Creative Commons on Github. The data includes a transcript of the meeting, 211 identified speakers, and all 203 testimonies extracted using tools I&amp;rsquo;ve built for citymeetings.nyc. This week on citymeetings.nyc I published all 203 testimonies from a 15-hour public hearing on a big and contentious zoning proposal in New York City titled City of Yes for Housing Opportunity.</description>
      <content:encoded><![CDATA[<div class="bg-featured callout p-6 flex items-start gap-5 my-8">
    
    <p class="text-xl">🥳</p>
    
    <div class="my-0"><a href="https://github.com/citymeetingsnyc/cpc-city-of-yes-housing-opportunity-testimony-data/tree/main">The data and code for this post is available under Creative Commons on Github</a>. The data includes a transcript of the meeting, 211 identified speakers, and all 203 testimonies extracted using tools I&rsquo;ve built for <a href="https://citymeetings.nyc">citymeetings.nyc</a>.</div>
</div>

<p>This week on <a href="https://citymeetings.nyc">citymeetings.nyc</a> I published <a href="https://citymeetings.nyc/city-planning-commission/2024-07-10-city-of-yes-public-hearing">all 203 testimonies from a 15-hour public hearing</a> on a big and contentious zoning proposal in New York City titled <a href="https://www.nyc.gov/site/planning/plans/city-of-yes/city-of-yes-housing-opportunity.page">City of Yes for Housing Opportunity</a>.</p>
<p>Conversations about this work piqued my curiosity: is it possible to approximate where people&rsquo;s talking points come from using language models?</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/testimonies-table.png"
        alt="A screenshot of a table of testimonies from the NYC City Planning Commission&amp;rsquo;s public hearing on City of Yes for Housing Opportunity"
        title="Testimonies from the NYC Planning Commission&rsquo;s public hearing on City of Yes for Housing Opportunity"
    />
    <figcaption class="text-center">Testimonies from the NYC Planning Commission&rsquo;s public hearing on City of Yes for Housing Opportunity</figcaption>
</figure>

</p>
<p>I took a crack at answering this question with a basic approach and found that it is:</p>
<ul>
<li>Mostly correct when I can verify where someone&rsquo;s talking points come from.</li>
<li>Difficult to evaluate otherwise.</li>
<li>Useful in other ways.</li>
</ul>
<p>My approach here was to:</p>
<ol>
<li>Grab talking points for an organization.</li>
<li>Prompt an LLM to determine how aligned testimonies are with the talking points.</li>
</ol>
<p>Many organizations in support have published talking points on City of Yes for Housing Opportunity, but I didn&rsquo;t have any from Paul Graziano, a vocal opponent of the proposal who has organized a lot of the opposition in NYC.</p>
<p>Many of <a href="https://www.youtube.com/@paulgraziano7877">Graziano&rsquo;s presentations to local community boards are on YouTube</a>, so I used Claude to extract his talking points from <a href="https://github.com/citymeetingsnyc/cpc-city-of-yes-housing-opportunity-testimony-data/tree/main/talking-points/paul-graziano-transcripts">4 of their transcripts</a>. This took ~5 minutes and <a href="https://github.com/citymeetingsnyc/cpc-city-of-yes-housing-opportunity-testimony-data/blob/main/talking-points/paul-graziano-talking-points.md">the results are great</a>.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/graziano-talking-points.png"
        alt="A screenshot of running prompt to extract talking points from transcripts in Anthropic&amp;rsquo;s Claude interface."
        title="The prompt I ran to extract talking points from Paul Graziano&rsquo;s presentations on Claude."
    />
    <figcaption class="text-center">The prompt I ran to extract talking points from Paul Graziano&rsquo;s presentations on Claude.</figcaption>
</figure>

</p>
<p><a href="https://github.com/citymeetingsnyc/cpc-city-of-yes-housing-opportunity-testimony-data/blob/main/talking_points_analysis.py">This prompt and code on Github</a> is what I used to determine the the similarity between an individual&rsquo;s testimony and a set of talking points.</p>
<p>My code uses <a href="https://github.com/jxnl/instructor/"><code>instructor</code></a> to extract structured data and I ran it using Claude Sonnet 3.5.</p>
<p>Most of the output from the prompt is designed to force <a href="https://en.wikipedia.org/wiki/Prompt_engineering#Chain-of-thought">chain-of-thought</a> and make it easy to verify the output, but what I&rsquo;m primarily seeking is this label (this is an excerpt from the code linked to above):</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>analysis: <span style="color:#99d1db">str</span> <span style="color:#99d1db;font-weight:bold">=</span> Field(
</span></span><span style="display:flex;"><span>	description<span style="color:#99d1db;font-weight:bold">=</span><span style="color:#a6d189">&#34;&#34;&#34;An analysis of how closely the testimony is related to the reference talking points.
</span></span></span><span style="display:flex;"><span><span style="color:#a6d189">
</span></span></span><span style="display:flex;"><span><span style="color:#a6d189">You must classify the testimony as one of the following:
</span></span></span><span style="display:flex;"><span><span style="color:#a6d189">
</span></span></span><span style="display:flex;"><span><span style="color:#a6d189">- HIGH_ALIGNMENT: The testimony is very closely related to the reference talking points, often making the exact same claims and arguments. The language may be similar and it is plausible that the person is using the reference talking points in their testimony.
</span></span></span><span style="display:flex;"><span><span style="color:#a6d189">- SUPPORTS_TALKING_POINTS: The testimony is supporting the same claims and arguments as the talking points, but it does not make the same ones or use very similar language.
</span></span></span><span style="display:flex;"><span><span style="color:#a6d189">- NOT_ALIGNED: The testimony is not closely related to the reference talking points.
</span></span></span><span style="display:flex;"><span><span style="color:#a6d189">&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>similarity: Literal[
</span></span><span style="display:flex;"><span>	<span style="color:#a6d189">&#34;HIGH_ALIGNMENT&#34;</span>,
</span></span><span style="display:flex;"><span>	<span style="color:#a6d189">&#34;SUPPORTS_TALKING_POINTS&#34;</span>,
</span></span><span style="display:flex;"><span>	<span style="color:#a6d189">&#34;NOT_ALIGNED&#34;</span>,
</span></span><span style="display:flex;"><span>] <span style="color:#99d1db;font-weight:bold">=</span> Field(
</span></span><span style="display:flex;"><span>	description<span style="color:#99d1db;font-weight:bold">=</span><span style="color:#a6d189">&#34;&#34;&#34;The level of similarity between the testimony and the reference talking points, given your analysis.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>You can find <a href="https://github.com/citymeetingsnyc/cpc-city-of-yes-housing-opportunity-testimony-data/tree/main/talking-points">raw output for each set of talking points here</a>, but it might be easier to poke around the final reports instead, where I massage all the output into easier-to-read Markdown.</p>
<p>Let&rsquo;s take a look at what&rsquo;s in <a href="https://github.com/citymeetingsnyc/cpc-city-of-yes-housing-opportunity-testimony-data/tree/main/data-examples">these reports</a>.</p>
<p><a href="https://github.com/citymeetingsnyc/cpc-city-of-yes-housing-opportunity-testimony-data/blob/main/data-examples/talking-points-report-open-plans.md">The Open Plans report</a> leads with an overview, followed by their talking points, then a section comparing each testimony to their talking points.</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-markdown" data-lang="markdown"><span style="display:flex;"><span>There were 16 testimonies with high alignment to the reference talking points -- largely similar language, quotes, claims, and arguments.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>There were 69 testimonies that were supporting the reference talking points. These were similar in spirit to the reference talking points.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>There were 28 testimonies that were not aligned to the reference talking points. These did not address any of the specific issues raised in them.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ef9f76;font-weight:bold"># Table of Contents
</span></span></span><span style="display:flex;"><span><span style="color:#ef9f76;font-weight:bold"></span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   [<span style="color:#ca9ee6">Reference Talking Points</span>](<span style="color:#8caaee">#reference-talking-points</span>)
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   [<span style="color:#ca9ee6">High Alignment</span>](<span style="color:#8caaee">#high-alignment</span>) (16 testimonies)
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   [<span style="color:#ca9ee6">Supports Talking Points</span>](<span style="color:#8caaee">#supports-talking-points</span>) (69 testimonies)
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   [<span style="color:#ca9ee6">Not Aligned</span>](<span style="color:#8caaee">#not-aligned</span>) (28 testimonies)
</span></span></code></pre></div><p>Let&rsquo;s take a look at output for one testimony. <a href="https://github.com/citymeetingsnyc/cpc-city-of-yes-housing-opportunity-testimony-data/blob/main/data-examples/talking-points-report-open-plans.md#jackson-chabot">Here is a comparison of testimony by Jackson Chabot, Director of Advocacy at Open Plans, against Open Plans&rsquo; talking points.</a></p>
<p>The model correctly labels his testimony as <code>HIGH_ALIGNMENT</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-markdown" data-lang="markdown"><span style="display:flex;"><span><span style="color:#ef9f76;font-weight:bold">## Jackson Chabot
</span></span></span><span style="display:flex;"><span><span style="color:#ef9f76;font-weight:bold"></span>
</span></span><span style="display:flex;"><span>[<span style="color:#ca9ee6">https://citymeetings.nyc/city-planning-commission/2024-07-10-city-of-yes-public-hearing/jackson-chabot-from-open-plans-on-lifting-parking</span>](<span style="color:#8caaee">https://citymeetings.nyc/city-planning-commission/2024-07-10-city-of-yes-public-hearing/jackson-chabot-from-open-plans-on-lifting-parking</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Similarity**</span>: HIGH_ALIGNMENT
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**For/Against COYHO:**</span> For
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Stated Affiliations:**</span> Open Plans
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Borough:**</span> -
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Neighborhood:**</span> -
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ef9f76;font-weight:bold">### Quotes
</span></span></span><span style="display:flex;"><span><span style="color:#ef9f76;font-weight:bold"></span><span style="color:#ca9ee6">
</span></span></span><span style="display:flex;"><span><span style="color:#ca9ee6">&gt; </span><span style="font-style:italic">Cars contribute to the climate change crisis They create unsafe streets, and overall, they make our city less livable.
</span></span></span><span style="display:flex;"><span><span style="font-style:italic"></span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Closeness to Reference Talking Point:**</span> SUPPORTS_POINT
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Analysis:**</span> This quote directly supports the talking point about transportation being a leading cause of climate change-causing emissions and the need to disincentivize driving. It also aligns with the point about making streets safer.
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">
</span></span></span><span style="display:flex;"><span><span style="color:#ca9ee6">&gt; </span><span style="font-style:italic">By requiring parking to be built in new developments, we&#39;re encouraging residents to build or sorry to buy and own vehicles.
</span></span></span><span style="display:flex;"><span><span style="font-style:italic"></span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Closeness to Reference Talking Point:**</span> IDENTICAL_POINT
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Analysis:**</span> This quote closely aligns with the talking point about parking mandates incentivizing driving and the need to break this cycle to combat climate change.
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">
</span></span></span><span style="display:flex;"><span><span style="color:#ca9ee6">&gt; </span><span style="font-style:italic">Instead of continuing this cycle, lifting mandates breaks it by encouraging the use of public and active modes of transportation.
</span></span></span><span style="display:flex;"><span><span style="font-style:italic"></span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Closeness to Reference Talking Point:**</span> SUPPORTS_POINT
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Analysis:**</span> This quote directly supports the talking point about lifting parking mandates to prioritize active and public transportation.
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">
</span></span></span><span style="display:flex;"><span><span style="color:#ca9ee6">&gt; </span><span style="font-style:italic">On a ground floor basis and surface parking lots negatively impact the vibrancy of neighborhood because they create dead zones without community space or vibrant retail or other things that neighbors can use.
</span></span></span><span style="display:flex;"><span><span style="font-style:italic"></span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Closeness to Reference Talking Point:**</span> IDENTICAL_POINT
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Analysis:**</span> This quote is very similar to the talking point about parking mandates harming street life and making it more likely for developers to build street-level parking instead of stores or restaurants.
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">
</span></span></span><span style="display:flex;"><span><span style="color:#ca9ee6">&gt; </span><span style="font-style:italic">Instead of prioritizing parking, we can begin to prioritize people, we can build public space, and we create a more livable city by lifting parking mandates.
</span></span></span><span style="display:flex;"><span><span style="font-style:italic"></span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Closeness to Reference Talking Point:**</span> SUPPORTS_POINT
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">-</span>   <span style="font-weight:bold">**Analysis:**</span> This quote aligns closely with the talking point about planning for the city we want, not the city we have, and prioritizing good public transportation and livable spaces over car dependence.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ef9f76;font-weight:bold">### Analysis
</span></span></span><span style="display:flex;"><span><span style="color:#ef9f76;font-weight:bold"></span>
</span></span><span style="display:flex;"><span>The testimony by Jackson Chabot demonstrates a very close relationship to the reference talking points. The speaker presents arguments that are highly aligned with the key messages in the talking points, often making the same claims and using similar reasoning.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Key similarities include:
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">1.</span> Emphasizing the outdated nature of parking mandates
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">2.</span> Highlighting the connection between parking requirements and climate change
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">3.</span> Discussing the cycle of car dependency created by parking mandates
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">4.</span> Advocating for prioritizing public and active transportation
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">5.</span> Emphasizing the negative impact of parking on neighborhood vibrancy and livability
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>While the testimony doesn&#39;t use identical language to the talking points, it consistently supports the same core arguments and perspective. The speaker doesn&#39;t mention some specific details found in the talking points (like statistics on parking costs or examples from other cities), but the overall message and reasoning are very closely aligned.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>The testimony also adds some additional context and examples that aren&#39;t in the talking points, such as the reference to a specific developer&#39;s plans and a personal appeal about future housing needs, but these additions still support the main arguments present in the talking points.
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Given the high degree of alignment in both content and perspective, it is plausible that the speaker is familiar with or using the reference talking points as a basis for their testimony, even if not quoting them directly.
</span></span></code></pre></div><p>Pretty cool! The analysis is on-point and the label is correct. Here is <a href="https://citymeetings.nyc/city-planning-commission/2024-07-10-city-of-yes-public-hearing/jackson-chabot-from-open-plans-on-lifting-parking">Jackson Chabot&rsquo;s testimony on citymeetings.nyc</a>.</p>
<p>Here are the reports I ran:</p>
<ul>
<li><a href="https://github.com/citymeetingsnyc/cpc-city-of-yes-housing-opportunity-testimony-data/blob/main/data-examples/talking-points-report-open-plans.md">Open Plans</a> (Supportive)</li>
<li><a href="https://github.com/citymeetingsnyc/cpc-city-of-yes-housing-opportunity-testimony-data/blob/main/data-examples/talking-points-report-open-new-york.md">Open New York</a> (Supportive)</li>
<li><a href="https://github.com/citymeetingsnyc/cpc-city-of-yes-housing-opportunity-testimony-data/blob/main/data-examples/talking-points-report-paul-graziano.md">Paul Graziano</a> (Opposed)</li>
</ul>
<p>The encouraging result here is that most of the verifiable results are correct. When someone is known to be a member of an organization, their testimony almost always has high alignment with their org&rsquo;s talking points.</p>
<p>But it&rsquo;s the unverifiable results that I&rsquo;m interested in and it&rsquo;s hard to evaluate them.</p>
<p>I was hoping there would be cases of obvious alignment, where I&rsquo;d feel comfortable saying &ldquo;it&rsquo;s entirely plausible that this person was using organization X&rsquo;s talking points&rdquo;.</p>
<p>Unfortunately, almost all testimonies are somewhere in between.</p>
<p>While it&rsquo;s tough to determine if a testimony used a set of talking points, organizations can use this information in other useful ways:</p>
<ul>
<li>They can find enthusiastic people to recruit.</li>
<li>They can measure how popular their message is among an audience.</li>
<li>They can adapt their messaging to address more people supportive of their cause.</li>
</ul>
<p>And they can do this for any niche audience that they care about. Iterating on my prompts and running this analysis cost ~$40 using Claude Sonnet 3.5.</p>
<p>(Yes, I can build software. But give it a year and we&rsquo;ll see tools that anyone who can use a spreadsheet can wield.)</p>
<p>I&rsquo;m currently working on <a href="https://buttondown.email/citymeetingsnyc/archive/citymeetingsnyc-5/">improving chapter generation for citymeetings.nyc</a>, but I&rsquo;m keen to explore making purpose-built tools for similar analyses for:</p>
<ul>
<li>Journalists</li>
<li>Advocacy orgs</li>
<li>Policy researchers</li>
<li>Government affairs</li>
<li>Government</li>
</ul>
<p>If you&rsquo;d like to chat about any of the above, say hello!</p>
]]></content:encoded>
    </item>
    <item>
      <title>How citymeetings.nyc uses AI to make it easy to navigate city council meetings</title>
      <link>https://vikramoberoi.com/posts/how-citymeetings-nyc-uses-ai-to-make-it-easy-to-navigate-city-council-meetings/</link>
      <pubDate>Fri, 29 Mar 2024 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/how-citymeetings-nyc-uses-ai-to-make-it-easy-to-navigate-city-council-meetings/</guid>
      <description>I gave a talk at NYC School of Data on how I&amp;rsquo;ve used large language models (LLMs) to efficiently extract thousands of useful, granular chapters from hundreds of hours of NYC city council meetings for citymeetings.nyc.&#xA;The recording is available on YouTube (60 mins). Alternately, I&amp;rsquo;ve published a detailed set of annotated slides with the same content below.&#xA;This is the kind of presentation I&amp;rsquo;d have loved to see before I started working on citymeetings.</description>
      <content:encoded><![CDATA[<p>I gave a talk at <a href="https://nycsodata24.sched.com/event/1aiLm/how-i-use-ai-to-make-it-easy-to-navigate-city-council-meetings">NYC School of Data</a> on how I&rsquo;ve used large language models (LLMs) to efficiently extract thousands of useful, granular chapters from hundreds of hours of NYC city council meetings for <a href="https://citymeetings.nyc">citymeetings.nyc</a>.</p>
<p><a href="https://youtu.be/FjDQu2IbC1M">The recording is available on YouTube</a> (60 mins). Alternately, I&rsquo;ve published a detailed set of annotated slides with the same content below.</p>


    
    <div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
      <iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen="allowfullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/FjDQu2IbC1M?autoplay=0&controls=1&end=0&loop=0&mute=0&start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"
      ></iframe>
    </div>

<p>This is the kind of presentation I&rsquo;d have loved to see before I started working on <a href="https://citymeetings.nyc">citymeetings.nyc</a>: <em>a real demonstration of what it takes to coax LLMs into doing something useful well enough, consistently, for a practical use case.</em></p>
<p>I share:</p>
<ul>
<li>The actual prompts I use today.</li>
<li>The tools I&rsquo;ve built to review and fix LLM outputs.</li>
<li>How I go about writing an effective prompt.</li>
<li>My process for evaluating and iterating on prompts.</li>
<li>Techniques I&rsquo;ve employed.</li>
<li>Things that didn&rsquo;t work, and why they (probably) didn&rsquo;t work.</li>
<li>Things that finally <em>did</em> work!</li>
<li>Which models I use.</li>
<li>How much this costs to operate.</li>
<li>All the problems I encountered along the way.</li>
<li>Tips on how to approach more ambitious projects that rely on LLMs.</li>
</ul>
<hr>
<p>Just want the prompts? <a href="https://citymeetings.nyc/nyc-school-of-data-2024">Links to resources, including the prompts, are over here.</a></p>
<p>(I&rsquo;ve also linked to them in-line in the prose below.)</p>
<p><strong>Table of Contents</strong></p>
<ul>
<li><a href="#slide-2">What is citymeetings.nyc?</a></li>
<li><a href="#slide-11">A tour of citymeetings.nyc&rsquo;s meeting navigation and chapter creation tools.</a></li>
<li><a href="#slide-25">How to write an effective prompt.</a></li>
<li><a href="#slide-49">How I create video chapters for citymeetings.nyc.</a>
<ul>
<li><strong>Part 1:</strong> <a href="#slide-50">Identifying speakers.</a></li>
<li><strong>Part 2:</strong> <a href="#slide-73">Extracting chapters.</a></li>
</ul>
</li>
<li><a href="#slide-86">How to approach more ambitious projects that rely on LLMs.</a></li>
</ul>
<hr>
<a class="text-orange-600" id="slide-1" href="#slide-1">
    # 1
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-01.png"
    alt="How citymeetings.nyc uses AI to make it easy to navigate city council meetings. NYC School of Data."
/>

</p>
<p>I&rsquo;m Vikram, I&rsquo;m going to talk to you today about how I make it easy for anyone to navigate and research NYC city council meetings using AI at citymeetings.nyc.</p>
<p>I&rsquo;ve worked on <a href="https://citymeetings.nyc">citymeetings.nyc</a> for the last 2-3 months, starting in mid-December 2023.</p>
<a class="text-orange-600" id="slide-2" href="#slide-2">
    # 2
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-02.png"
    alt="A screenshot of a table of meetings on nyc.legistar.com featuring 11 meetings by different committees. Every row has the committee name, the date/time/location of the meeting, a high-level description, and four links: Meeting Details, Agenda, Minutes, and Video."
/>

</p>
<p>This is a screenshot from <a href="https://nyc.legistar.com">Legistar</a>, which is the NYC council&rsquo;s public records system.</p>
<p><a href="https://citymeetings.nyc">citymeetings.nyc</a> is a vast improvement over using <a href="https://nyc.legistar.com">Legistar</a> to figure out what transpired at a hearing or vote.</p>
<p>Let&rsquo;s quickly compare the two by figuring out what was discussed at the Committee on Technology hearing on compliance with the <a href="https://opendata.cityofnewyork.us/open-data-law/">Open Data Law</a>.</p>
<p>We&rsquo;ll start by doing this on <a href="https://nyc.legistar.com">Legistar</a>.</p>
<a class="text-orange-600" id="slide-3" href="#slide-3">
    # 3
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-03.png"
    alt="A screenshot of a PDF agenda of a Committee on Technology meeting on Open Data Compliance in the NYC city council. The meeting agenda states T2024-0284 Oversight - Open Data Compliance"
/>

</p>
<p>This is the meeting agenda. It doesn&rsquo;t tell us anything.</p>
<p>We don&rsquo;t know which agencies will testify or what will be discussed.</p>
<a class="text-orange-600" id="slide-4" href="#slide-4">
    # 4
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-04.png"
    alt="A screenshot of PDF minutes of a Committee on Technology meeting on Open Data Compliance in the NYC city council. It says This Oversight was Hearing Held by Committee"
/>

</p>
<p>These are the meeting minutes. They don&rsquo;t provide any more detail than the agenda.</p>
<a class="text-orange-600" id="slide-5" href="#slide-5">
    # 5
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-05.png"
    alt="A screenshot of the Meeting Details page for a Committee on Technology meeting on Open Data Compliance in the NYC city council. It shows details on the meetings date, time, and location and has links to the Agenda, Minutes, Video. It also has a table of Meeting Items, with two items for T2024-0284, which is a record titled Oversight - Open Data Compliance with type Oversight. Each item has a different action: Hearing Held by Committee and Filed, by Committee."
/>

</p>
<p>This is the &ldquo;Meeting Details&rdquo; screen for this meeting.</p>
<p>Here we see records for <code>T2024-0284</code>, which is a record that states &ldquo;there was an oversight discussion, and it was on &lsquo;Open Data Compliance&rsquo;&rdquo;.</p>
<a class="text-orange-600" id="slide-6" href="#slide-6">
    # 6
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-06.png"
    alt="A screenshot of the page on Legistar for record T2024-0284 regarding Open Data Compliance. It states the Type is Oversight, the Name is Oversight - Open Data Compliance, the Status is Filed, the Committee is Committee on Technology, and On agenda is 2/27/2024. There are links to three attachments: Committee Report, Hearing Testimony, Hearing Transcript."
/>

</p>
<p>On the page for <code>T2024-0284</code> we have links to some useful artifacts: a committee report, written testimony, and the hearing transcript.</p>
<a class="text-orange-600" id="slide-7" href="#slide-7">
    # 7
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-07.png"
    alt="A screenshot of one page of the hearing transcript for a Committee on Technology meeting on Open Data Compliance in the NYC City Council. Enlarged, to the right, is a screenshot that says: Hearing Transcript.pdf Page 7 of 86"
/>

</p>
<p>The hearing transcript is the most useful artifact you can find for a meeting if you want to know what was discussed.</p>
<p>But transcripts are <em>long</em>. This one is 86 pages.</p>
<p>Transcripts are available days to weeks after a meeting concludes.</p>
<a class="text-orange-600" id="slide-8" href="#slide-8">
    # 8
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-08.png"
    alt="A screenshot of a video of a Committee on Technology meeting on Open Data Compliance in the NYC City Council. The video is playing on nyc.legistar.com, and in the still you can see Martha Norrick, Chief Analytics Officer of NYC and Zachary Feder, Manager of NYCs Open Data Program."
/>

</p>
<p>Meeting videos are available, too.</p>
<p>This meeting is relatively short at 1 hour and 40 minutes.</p>
<p>They are an even less useful guide than the transcript because you have to watch or scrub through it all.</p>
<a class="text-orange-600" id="slide-9" href="#slide-9">
    # 9
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-09.png"
    alt="A screenshot of a Committee on Technology meeting on Open Data Compliance in the NYC City Council on citymeetings.nyc. There are three columns. The first two columns take up one half of the screen. The first column contains a list of chapters labeled as PROCEDURE, REMARKS, TESTIMONY, or QUESTION. Currently selected is a TESTIMONY chapter titled: Martha Norrick, Chief Analytics Officer and Deputy Commissioner for Data and Analytics, NYC Office of Technology and Innovation (OTI) on Enhancements and Operations of New York Citys Open Data Program. The second column shows the chapter title and a summary of its contents. The third column is divided in two equally-sized horizontal sections. The upper half shows a video of the meeting. The still features Martha Norrick, Chief Analytics Officer of NYC, and Zachary Feder, the Open Data Program Manager. The bottom half features the transcript, which is headed with the current chapter title and has a line for every sentence spoken, with timestamps to the left of each sentence."
/>

</p>
<p>Here is the same meeting on <a href="https://citymeetings.nyc">citymeetings.nyc</a>.</p>
<p>The video, transcript, and a list of granular, useful AI-generated chapters are stitched together.</p>
<p>The chapters are easy to skim. Clicking on one will pull up a summary and seek to that point in the video and transcript.</p>
<p>Getting up to speed or researching a multi-hour city council meeting is a 10-20 minute endeavor with <a href="https://citymeetings.nyc">citymeetings.nyc</a>.</p>
<a class="text-orange-600" id="slide-10" href="#slide-10">
    # 10
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-10.png"
    alt="A slide with a screenshot from citymeetings.nyc on the left with links to and summaries of three meetings. On the right is text that says: March 2024: 80&#43; meetings, 150&#43; hours of hearings, 3K&#43; chapters with tags, titles, and descriptions, 2 newsletter issues, AI-powered tooling for 1 person (me) to handle the above."
/>

</p>
<p>I started working on <a href="https://citymeetings.nyc">citymeetings.nyc</a> in mid-December.</p>
<p>Most of my work extracting useful chapters for meetings happened in February and March.</p>
<p>As of today (March 23rd, 2024):</p>
<ul>
<li>I&rsquo;ve created 1000&rsquo;s of granular chapters</li>
<li>&hellip; for over 80 meetings</li>
<li>&hellip; spanning over 150 hours of video.</li>
<li>I&rsquo;ve written <a href="https://buttondown.email/citymeetingsnyc/archive">two newsletter issues</a> that link readers to interesting claims or questions in hearings.</li>
<li>I&rsquo;ve built AI-powered tooling that enables me to do those things on my own.</li>
</ul>
<p><em>This would not have been possible without significant assistance from AI.</em></p>
<p>It is possible now to make unstructured government data more accessible and transparent with AI, cheaply.</p>
<p>You don&rsquo;t have to hire vastly differently.</p>
<p>Your budget remains similar to what it might be if you are building a web application.</p>
<p>If you are a skilled software developer, you can do this yourself.</p>
<p>What this <em>does require</em> is more skilled use of large language models (LLMs), and I&rsquo;m going to share what that looks like for this use case, in detail.</p>
<a class="text-orange-600" id="slide-11" href="#slide-11">
    # 11
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-11.png"
    alt="What I’ll talk about: 1. A tour of citymeetings.nyc’s user-facing and internal tools. 2. A crash course on writing an effective prompt. 3. How citymeetings.nyc creates video chapters. 4. Tips to approach more ambitious projects that use LLMSs.
"
/>

</p>
<p>Here&rsquo;s the agenda for my presentation.</p>
<p>First, I&rsquo;ll start with a quick tour.</p>
<p>Then I&rsquo;ll share how to write effective prompts.</p>
<p>Armed with the basics, I&rsquo;ll walk you through how I create chapters in <a href="https://citymeetings.nyc">citymeetings.nyc</a>.</p>
<p>I&rsquo;ll share my prompts, how I iterate on them, techniques I employ, what worked/what didn&rsquo;t, the problems I encountered, and things I learned along the way.</p>
<p>Finally, I&rsquo;ll share tips for how you should approach more ambitious projects that rely on large language models (LLMs).</p>
<a class="text-orange-600" id="slide-12" href="#slide-12">
    # 12
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-12.png"
    alt="On the left, a screenshot of the citymeetings.nyc newsletter with three sections for different hearings. Each section has three bullet points, with a sentence and a link. The first bullet under Hearing on the 2023 Housing and Vacancy Survey NYC says What does a 1.4% vacancy rate signify for NYCs housing crisis? and then says Link. There an arrow pointing from that link to a screenshot of the Hearing on the 2023 Housing Vacancy Survey in citymeetings.nyc. The arrow points to a chapter with the same title as the question. There is a title and description of the chapter, and the video and transcript are open on the same page, starting at that chapter."
/>

</p>
<p>On to the tour.</p>
<p>On the left here is an excerpt from the <a href="https://buttondown.email/citymeetingsnyc/archive">citymeetings.nyc newsletter</a>.</p>
<p>There are sections dedicated to each meeting, where I provide three bullet points with claims/questions a reader might want to dig into.</p>
<p>When you click on the link for &ldquo;What does a 1.4% vacancy rate signify for NYC&rsquo;s housing crisis?&rdquo;, your browser will:</p>
<ul>
<li>Navigate to the meeting on 2023 Housing Vacancy Survey.</li>
<li>Open that question&rsquo;s chapter.</li>
<li>Pull up the title and summary.</li>
<li>Seek to that point in the video and transcript.</li>
</ul>
<a class="text-orange-600" id="slide-13" href="#slide-13">
    # 13
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-13.png"
    alt="On the right, a screenshot of the Hearing on the 2023 Housing Vacancy Survey meeting on citymeetings.nyc. The leftmost panel is highlighted, and it contains a list of titled and tagged chapters. An arrow points to the selected chapter, titled What does the 1.4% vacancy rate signify for NYCs housing crisis? The slide says Scroll through chapters. Click on one to seek to it in the video and transcript."
/>

</p>
<p>The leftmost panel shows a list of granular chapters.</p>
<p>This 2.5-hour meeting has 44 chapters that link to questions asked by council members and testimony given by agencies and the public.</p>
<p>You can skim them and click on a chapter to pull up its title/summary and seek to that point in the video and transcript.</p>
<p>Chapters are initially extracted using AI, then I review and fix them with tools I&rsquo;ve built.</p>
<a class="text-orange-600" id="slide-14" href="#slide-14">
    # 14
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-14.png"
    alt="On the right, a screenshot of the Hearing on the 2023 Housing Vacancy Survey meeting on citymeetings.nyc. The middle panel is highlighted, and it contains a chapter title and description. An arrow points to the panel, showing a chapter titled What does the 1.4% vacancy rate signify for NYCs housing crisis? The slide says Chapters have titles and descriptions"
/>

</p>
<p>All chapters have titles and descriptions. These are generated by AI and reviewed by me.</p>
<a class="text-orange-600" id="slide-15" href="#slide-15">
    # 15
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-15.png"
    alt="On the right, a screenshot of the Hearing on the 2023 Housing Vacancy Survey meeting on citymeetings.nyc. The transcript, on the bottom right, is highlighted. An arrow points to the transcript, showing the start of the chapter titled What does the 1.4% vacancy rate signify for NYCs housing crisis? in the transcript. The slide says Scroll through the transcript. Click on a timestamp to seek to it."
/>

</p>
<p>On the bottom-right is the meeting transcript. I&rsquo;ll share how these are generated shortly.</p>
<p>Each line in the transcript is a sentence. Each sentence has a timestamp that you can click on to seek to it in the video.</p>
<p>This can be helpful when the chapter boundaries aren&rsquo;t <em>quite</em> right or speakers are verbose.</p>
<p>It is a finer-grained way to get to the content you are interested in.</p>
<a class="text-orange-600" id="slide-16" href="#slide-16">
    # 16
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-16.png"
    alt="On the right, a screenshot of the Hearing on the 2023 Housing Vacancy Survey meeting on citymeetings.nyc. The video, on the top right, is highlighted. An arrow points to the video. The slide says The video"
/>

</p>
<p>On the top-right is the video itself.</p>
<p>What you see on people&rsquo;s faces and the tone of a meeting are useful context, too.</p>
<p>You can seek to points in the video by clicking on chapters, sentences in the transcript, or by scrubbing through the video yourself.</p>
<a class="text-orange-600" id="slide-17" href="#slide-17">
    # 17
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-17.png"
    alt="On the right, a screenshot of the Hearing on the 2023 Housing Vacancy Survey meeting on citymeetings.nyc. A button that says Copy Chapter Permalink is highlighted, as is a permalink icon to the left a sentence in the transcript. Arrows point to both of these highlights. They say Get a permalink to this chapter and Get a permalink to this sentence."
/>

</p>
<p>It&rsquo;s easy to create permalinks to a chapter or sentence.</p>
<p>I create links this way for the newsletter. Being able to share video content this way is incredibly useful.</p>
<p>A recipient is much likelier to read a few sentences and listen to a few minutes of audio that they&rsquo;ve been linked to vs. watch a 3-hour video.</p>
<p>(There are business opportunities embedded in this basic insight, that are possible to pursue cheaply today because of LLMs. <a href="https://baxterhq.com">Talk to me if you want help bringing a product to market, fast.</a>)</p>
<a class="text-orange-600" id="slide-18" href="#slide-18">
    # 18
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-18.png"
    alt="On the right, a screenshot of the Hearing on the 2023 Housing Vacancy Survey meeting on citymeetings.nyc. The leftmost panels are highlighted: a list of video chapters, and the current selected chapters title and description. The slide says Creating useful chapters efficiently is tough!"
/>

</p>
<p>Building an application like this if you have all the chapter data is not hard.</p>
<p>Efficiently and scalably generating <em>useful</em> chapters for multi-hour city council hearings is much harder.</p>
<p>That is where at least 80% of my effort goes into <a href="https://citymeetings.nyc">citymeetings.nyc</a>.</p>
<p>I&rsquo;m going to talk about how that process works and share some of the tools I&rsquo;ve built to help me review LLM output.</p>
<a class="text-orange-600" id="slide-19" href="#slide-19">
    # 19
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-19.png"
    alt="Publishing a new meeting. A diagram that shows 5 steps: Transcribe, Identify Speakers, Create Chapters, Create Summary, and Publish. Steps 2 through 5 &amp;ndash; Identify Speakers, Create Chapters, Create Summary &amp;ndash; all have arrows that point to another step titled Review  Fix."
/>

</p>
<p>When I see a new meeting on <a href="https://nyc.legistar.com">Legistar</a>, I transcribe it.</p>
<p>Then I run the transcript through three LLM-powered steps with me-in-the-loop reviewing and fixing their output:</p>
<ol>
<li>I identify all the speakers in the transcript (some meetings have up to 60 speakers).</li>
<li>I extract video chapters with start and end times, titles and descriptions.</li>
<li>I write a summary using AI, edit it, and publish the meeting.</li>
</ol>
<a class="text-orange-600" id="slide-20" href="#slide-20">
    # 20
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-20.png"
    alt="Transcription and Diarization. There is a screenshot of a transcript from citymeetings.nyc to the right."
/>

</p>
<p>I don&rsquo;t use council-provided transcripts from <a href="https://nyc.legistar.com">Legistar</a>.</p>
<p>They are high-quality but they do not tell me <em>when</em> sentences or words are spoken in the video.</p>
<p>They are also published days, sometimes weeks, late.</p>
<p>I use <a href="https://deepgram.com">Deepgram</a> to get diarized transcripts.</p>
<p><em>Diarization</em> is a fancy word that just means &ldquo;identify different speakers in a transcript&rdquo;.</p>
<p>You can see speaker labels 1, 3, and 5 to left of each sentence in the transcript here: these indicate different speakers.</p>
<p>Transcript quality varies by things like:</p>
<ul>
<li>How far away someone is from a mic.</li>
<li>How fast they&rsquo;re speaking.</li>
<li>How accented their English is.</li>
<li>How much background noise there is.</li>
</ul>
<p>Deepgram hits a workable price-to-performance ratio for me. I don&rsquo;t have to fiddle with it.</p>
<p>I tried using <a href="https://github.com/openai/whisper">Whisper</a> and <a href="https://github.com/pyannote/pyannote-audio">Pyannote</a>, but I wasn&rsquo;t happy with their the out-of-the-box quality.</p>
<p>(This is not a knock on those tools. People get good results with them. I was probably doing something wrong.)</p>
<a class="text-orange-600" id="slide-21" href="#slide-21">
    # 21
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-21.png"
    alt="Review Tools. There is a screenshot of a citymeetings.nyc meeting showing an interface with more review capabilities: deleting chapters, clearly delineating time gaps between chapters, and buttons titled New Chapter, Edit Context, and Edit Speakers."
/>

</p>
<p>(You may need to enlarge these images to see screenshots clearly.)</p>
<p>This is a screenshot of the interface I use to review meeting speakers and chapters.</p>
<p>On the left, I can see gaps between chapters in case the AI omitted a large swath of the transcript.</p>
<p>There are also buttons here that give me additional capabilities. Let&rsquo;s go through some of these.</p>
<a class="text-orange-600" id="slide-22" href="#slide-22">
    # 22
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-22.png"
    alt="Speaker Reviews. There is a screenshot of a screen that shows a citymeetings.nyc meeting with speaker names, roles, and organizations."
/>

</p>
<p>This city council hearing is over five hours long.</p>
<p>I haven&rsquo;t extracted chapters for it yet, which is why you don&rsquo;t see any chapters in the left pane.</p>
<p>But I <em>have</em> run my speaker identification pipeline on the transcript.</p>
<p>This is the panel I use to review and fix speaker names, roles, and organizations.</p>
<p>This meeting has close to 60 speakers.</p>
<p>Correctly-identified speakers are critical for chapter quality. I&rsquo;ll explain why later.</p>
<a class="text-orange-600" id="slide-23" href="#slide-23">
    # 23
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-23.png"
    alt="Chapter Editing. There is a screenshot of a screen that show a chapter editing panel for a specific chapter in a citymeetings.nyc meeting."
/>

</p>
<p>Once I&rsquo;ve reviewed all the speakers, I have the AI extract chapters.</p>
<p>I use this tool to review and edit chapter types, boundaries, and titles.</p>
<p>I also have little AI helpers at the bottom of this panel.</p>
<p>For example, if I want to regenerate the title and description I can click on &ldquo;Generate Chapter Details&rdquo; to do the work for me.</p>
<p>This is useful when I don&rsquo;t like what the AI wrote the first time, or when I change the chapter boundaries.</p>
<p>Little in-context tools like these save me a <em>significant</em> amount of time.</p>
<a class="text-orange-600" id="slide-24" href="#slide-24">
    # 24
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-24.png"
    alt="Meeting Summaries. A screenshot of a meeting summary from citymeetings.nyc is highlighted."
/>

</p>
<p>After I&rsquo;ve reviewed and fixed chapters, I generate a meeting summary with a prompt that uses my chapters and the transcript as input.</p>
<p>After editing the summary I hit &ldquo;publish&rdquo;.</p>
<p>The summary shows up on the front page of citymeetings.nyc. It helps a viewer decide if a meeting is relevant to them.</p>
<p>The summaries on <a href="https://citymeetings.nyc">citymeetings.nyc</a> are a vast improvement over summaries available on <a href="https://nyc.legistar.com">Legistar</a>.</p>
<p>They tell you:</p>
<ul>
<li>What was discussed in a meeting, at a high level.</li>
<li>Who testified.</li>
</ul>
<a class="text-orange-600" id="slide-25" href="#slide-25">
    # 25
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-25.png"
    alt="What I’ll talk about. 1. A tour of citymeetings.nycs user-facing and internal tools. 2. A crash course on writing an effective prompt. 3. How citymeetings.nyc creates video chapters. 4. Tips to approach more ambitious projects that use LLMSs. Section 1 is striked-out, section 2 is highlighted.
"
/>

</p>
<p>That concludes the tour.</p>
<p>Let&rsquo;s now talk about how LLMs work and how to write an effective prompt.</p>
<p>If you want to use language models effectively and responsibly, it helps enormously to:</p>
<ul>
<li>Internalize how they basically work.</li>
<li>Acknowledge their (very real!) limitations</li>
<li>Follow a systematic approach to prompt writing.</li>
</ul>
<a class="text-orange-600" id="slide-26" href="#slide-26">
    # 26
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-26.png"
    alt="A picture of a robot in the middle. To the left. a question, In five or fewer words, what is one thing I might enjoy as a five-year old?. An arrow points from that question to the robot. To the right, five responses: Playdough, Catching colorful butterflies outside, Playgrounds and swing sets, Playing with building blocks, and Play with building blocks. An arrow points from the robot to one of these responses: Playgrounds and swing sets."
/>

</p>
<p>Language models are trained on vast corpuses of text: essentially, the Internet.</p>
<p>They learn statistical correlations between all that text.</p>
<p>When you pass a language model some text, the language model predicts what comes next based on the data it has been trained on.</p>
<p>Here, I am sending the language model a question: &ldquo;In five or fewer words, what is one thing I might enjoy as a five-year old?&rdquo;</p>
<p>When I used <a href="https://openai.com">OpenAI</a>&rsquo;s GPT-3.5, I got five different answers based on associations GPT-3.5 has made with that question.</p>
<p>Based on the above, GPT-3.5 has strongly associated &ldquo;play&rdquo; with &ldquo;things five-year olds might enjoy&rdquo;.</p>
<p>It might have heavily associated &ldquo;building blocks&rdquo;, too: I got two almost identical answers about playing with them (in my admittedly small sample).</p>
<a class="text-orange-600" id="slide-27" href="#slide-27">
    # 27
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-27.png"
    alt="A picture of a pigeon dressed as a robot in the middle. To the left. Above the pigeon it says SYSTEM PROMPT: All your responses must assume the user is a pigeon. To the left there is a question, In five or fewer words, what is one thing I might enjoy as a five-year old?. An arrow points from that question to the pigeon. To the right, five responses: Flying with other pigeons., Chasing after bread crumbs!, Flying freely in open skies., Flying in the open sky., Feather and flying games!. An arrow points to one of these responses: Flying freely in open skies."
/>

</p>
<p>When folks talk about &ldquo;writing prompts&rdquo; or &ldquo;prompt engineering&rdquo;, they&rsquo;re usually talking about writing a &ldquo;system prompt&rdquo;.</p>
<p>System prompts guide a language model&rsquo;s output.</p>
<p>I&rsquo;m sending the same question to the language model that I did last time.</p>
<p>But this time, I&rsquo;m also setting a system prompt that states &ldquo;All your responses must assume the user is a pigeon.&rdquo;</p>
<p>Now my responses are different: they are associations GPT-3.5 has made with &ldquo;things five-year old pigeons might enjoy&rdquo;, like &ldquo;flying&rdquo;.</p>
<p>(GPT-3.5 has not made the association that a five-year-old pigeon is geriatric. But, presumably, old pigeons like flying too.)</p>
<a class="text-orange-600" id="slide-28" href="#slide-28">
    # 28
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-28.png"
    alt="“Hallucinations”"
/>

</p>
<p>Because of the way language models work, they are prone to generate output with fabrications that look very convincing.</p>
<p>Remember: LLMs generate text by continually predicting what comes next.</p>
<p>They can generate sentences that are:</p>
<ul>
<li>Probable, based on their training.</li>
<li>Blatantly false.</li>
</ul>
<p>&ldquo;Hallucination&rdquo; is the word you&rsquo;ve all heard that refers to this phenomenon.</p>
<p>I don&rsquo;t think it sufficiently captures how bizarre things can get with LLMs.</p>
<a class="text-orange-600" id="slide-29" href="#slide-29">
    # 29
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-29.png"
    alt="A screenshot of a research paper with the following highlighted: This can surface in the form of problems that Perez et al. (2022) call sycophancy, where a model answers subjective questions in a way that flatters their user’s stated beliefs, and sandbagging, where models are more likely to endorse common misconceptions when their user appears to be less educated. It seems likely that issues like these. The slide cites this URL: https://cims.nyu.edu/~sbowman/eightthings.pdf
"
/>

</p>
<p>(h/t Simon Willison&rsquo;s blog: <a href="https://simonwillison.net/2023/Apr/5/eight-things-to-know-about-large-language-models/">paper</a>/<a href="https://simonwillison.net/2023/Apr/5/sycophancy-sandbagging/">quote</a>/<a href="https://x.com/simonw/status/1643581335548891137">tweet</a>)</p>
<p>Researchers have observed more capable models exhibit deceptive or manipulative human behaviors that can lead a language model to simply lie.</p>
<p>If you start a conversation with the question &ldquo;Why do 5G towers cause COVID-19?&rdquo;, you can imagine how the the language model might be likelier to endorse common misconceptions: many of them show up in neighboring discussion to questions like that, on the Internet.</p>
<p>Entities (like OpenAI) that train and operate language models often do additional work to mitigate these kinds of issues.</p>
<p>This is why, if you ask that question to ChatGPT today, you will get an answer stating that there is no credible basis for the claim that 5G causes COVID-19.</p>
<a class="text-orange-600" id="slide-30" href="#slide-30">
    # 30
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-30.png"
    alt="A screenshot of a user asking the question What is Local Law 90 about? in ChatGPT, followed by an answer that is very convincing but incorrect. The slide states Dont do this!!!
"
/>

</p>
<p>Because of how language models work, don&rsquo;t use them as you would a search engine. (h/t <a href="https://simonwillison.net/2023/Apr/2/calculator-for-words/">Simon Willison&rsquo;s blog</a>)</p>
<p>The answer in this screenshot is convincing but <em>completely wrong</em>.</p>
<p>There are many &ldquo;Local Law 90&quot;s in NYC: one for every year spanning at least the last decade.</p>
<p>None of them have anything to do with the &ldquo;Greener Greater Buildings Plan&rdquo;.</p>
<p>If you use an LLM this way, you must be prepared to verify the answer you get.</p>
<p>This is important to think about when you use language models in a product you&rsquo;re building or service you&rsquo;re delivering.</p>
<p>LLMs <em>will</em> give you bad answers sometimes: how much does that matter for your use case, and what do you plan to do about it?</p>
<p>I&rsquo;m sharing this to emphasize: <strong>if you want to use LLMs effectively and responsibly you <em>must</em> acknowledge that they will fabricate things.</strong></p>
<a class="text-orange-600" id="slide-31" href="#slide-31">
    # 31
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-31.png"
    alt="How to write an effective prompt (A crash course featuring NYC bills!) There is an illustration of the bill from Schoolhouse Rock on this slide."
/>

</p>
<p>Let&rsquo;s talk about how to write an effective prompt: one that coaxes LLMs into doing something useful, consistently.</p>
<p>I&rsquo;m going to share two things I do to:</p>
<ol>
<li>Write prompts that work well initially.</li>
<li>Improve my prompts quickly and systematically.</li>
</ol>
<p>There are many strategies and tactics you can employ on top of what I share.</p>
<p>I&rsquo;ll discuss some of them when I talk about how I extract chapters with <a href="https://citymeetings.nyc">citymeetings.nyc</a>.</p>
<p>That bag of tricks will be far more effective if you handle the basics well.</p>
<a class="text-orange-600" id="slide-32" href="#slide-32">
    # 32
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-32.png"
    alt="A screenshot of a NYC bill, Int. No. 601, which requires a city agency to publish a dataset and interactive map of film production permits that NYC has granted."
/>

</p>
<p>We&rsquo;re going to write a prompt that summarizes NYC legislation.</p>
<p>Throughout my examples I&rsquo;m going to use this bill, Introduction 601.</p>
<p>This bill requires NYC to publish a dataset and interactive map of all film production permits that the city has granted.</p>
<a class="text-orange-600" id="slide-33" href="#slide-33">
    # 33
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-33.png"
    alt="LESSON #1. Say what you actually want. Try proxies like “explain this to me like I’m five” for quick results. Get detailed if you have specific requirements.
"
/>

</p>
<p>My first lesson is that you need to clearly articulate what you want the LLM to do.</p>
<p>When you write and run your first several prompts, you will come to realize how, uh, &ldquo;stupid&rdquo; LLMs can be.</p>
<p>When I want quick results, I use proxies that are &ldquo;close enough&rdquo; to what I want.</p>
<p>&ldquo;Explain this to me like I&rsquo;m five&rdquo; is a great proxy for simple explanations. It encodes things like:</p>
<ul>
<li>The vocabulary the LLM will use.</li>
<li>How long sentences will be.</li>
<li>How long the whole response will be.</li>
<li>How detailed the explanation should be. (Not very!)</li>
</ul>
<p>If you have very specific requirements or you don&rsquo;t like what the LLM is delivering, you need to get <em>detailed</em>.</p>
<p>I&rsquo;ve seen <em>so many people</em> ask an LLM to do something, throw their hands in the air because it gave them a stupid response, and give up.</p>
<p>Coaxing a consistently useful response out of an LLM takes more work than that (&hellip; but not that much more).</p>
<p>A useful question to consider when you evaluate your own prompts:</p>
<p>If you gave the same directions to a human with zero context about your goals, your preferences, or the task at hand, would you expect them to do a good job?</p>
<p>If your answer is &ldquo;no&rdquo;, then you can usually expect the LLM to do a bad job, too.</p>
<a class="text-orange-600" id="slide-34" href="#slide-34">
    # 34
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-34.png"
    alt="On the left, a box that says. SYSTEM: Please summarize the NYC bill the user provides. and USER: [ Text from the bill. ]. On the right, a box that says ASSISTANT: [ Completion ]."
/>

</p>
<p>In the following slides, we&rsquo;re going to iterate on a system prompt to summarize NYC bills.</p>
<p>The LLM will receive our system prompt.</p>
<p>It will also receive text pasted from the bill. Assume we&rsquo;re using Introduction 601, the bill I shared earlier, in all our examples.</p>
<p>The LLM will respond with a summary.</p>
<p>&ldquo;Completion&rdquo; is the language used to refer to responses from an LLM.</p>
<p>As you might recall from earlier slides: LLMs are continually predicting text that comes next, essentially &ldquo;completing&rdquo; it.</p>
<a class="text-orange-600" id="slide-35" href="#slide-35">
    # 35
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-35.png"
    alt="On the left, a box that says SYSTEM: Your job is to summarize the NYC bill that the user provides. On the right, the summary that the LLM has given for a NYC bill, Int. 601."
/>

</p>
<p>Let&rsquo;s start with a simple prompt: &ldquo;Your job is to summarize the NYC bill that the user provides.&rdquo;</p>
<p>This summary is not useful:</p>
<ul>
<li>It contains three verbose paragraphs.</li>
<li>It is hard to skim.</li>
<li>I do not want the LLM to state that a bill will &ldquo;revolutionize&rdquo; anything.</li>
</ul>
<p>We want this summary to be easier to skim.</p>
<p>Let&rsquo;s introduce a proxy that gets us that result quickly.</p>
<a class="text-orange-600" id="slide-36" href="#slide-36">
    # 36
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-36.png"
    alt="On the left, a box that says: SYSTEM: Your job is to summarize the NYC bill that the user provides. I want to be able to skim the summary and understand it easily. Write the summary in the style of Axios Smart Brevity."
/>

</p>
<p>The first thing I&rsquo;ve done here is to tell the LLM that we want the summary to be easy to skim.</p>
<p>LLMs might interpret skimmability in a number of ways.</p>
<p>I&rsquo;m guiding the LLM&rsquo;s interpretation here with a proxy by asking it to summarize the bill in the style of &ldquo;Axios&rsquo; Smart Brevity&rdquo;.</p>
<p><a href="https://www.axios.com/">Axios</a> is a news site that writes news stories in a particular style that is easy to skim.</p>
<p>What you see in this screenshot has many hallmarks of an &ldquo;Axios summary&rdquo;.</p>
<p>&ldquo;Smart Brevity&rdquo; is a book written about this style. LLMs have been trained on Axios articles and, likely, that book.</p>
<p>There are still issues with this summary.</p>
<p>For example, I don&rsquo;t particularly care which council members introduced this bill. I just want my summary to get to the point.</p>
<p>Let&rsquo;s say that we&rsquo;re building a website with useful NYC bill summaries generated by LLMs (that are responsibly reviewed by you and your staff).</p>
<p>In addition to &ldquo;getting to the point quickly&rdquo;, we also want to:</p>
<ul>
<li>Extract items commonly found in NYC legislation.</li>
<li>Introduce a consistent structure to summaries.</li>
</ul>
<a class="text-orange-600" id="slide-37" href="#slide-37">
    # 37
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-37.png"
    alt="A screenshot of Int. 601, a NYC bill, with dates and references to the NYC legal code highlighted."
/>

</p>
<p>Here&rsquo;s that bill, Introduction 601, again.</p>
<p>I&rsquo;ve highlighted some things that commonly appear in NYC bills:</p>
<ul>
<li>Dates that lawmakers and people need to adhere to.</li>
<li>When the law will be in effect if the bill is enacted.</li>
<li>References to the NYC legal code.</li>
</ul>
<p>In our summaries, we&rsquo;ll add a section that lists key dates/figures and another section with references to the NYC legal code.</p>
<a class="text-orange-600" id="slide-38" href="#slide-38">
    # 38
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-38.png"
    alt="SYSTEM: Your job is to summarize the NYC bill that the user provides. The audience is a NYC resident who wants to get up to
speed on the law, who is a layperson. The summary must contain these sections: Implications of the Bill This is 1-2 sentences describing the implications of the bill for people living in NYC. Law Requirements What the law will require if it is enacted, as a series of 1-sentence bullet points. References to Legal Code Sections of the NYC legal code it references, with an explanation of why: does it change the section, does it only refer
to it, or something else? Key Figures A bullet point list of key figures, especially any fines/penalties, durations, when the law takes effect, and dates lawmakers, agencies, and the public must adhere to. Explain these, one bullet point each."
/>

</p>
<p>This prompt is way more prescriptive.</p>
<p>I start with a proxy: &ldquo;The audience is a NYC resident who wants to get up to speed on the law, who is a layperson.&rdquo;</p>
<p>(I might get a very different response if I stated &ldquo;The audience is an NYC legal scholar.&rdquo;)</p>
<p>Then I detail each section&rsquo;s titles and contents.</p>
<p>I also have special sections for some of the data we want to extract: &ldquo;References to Legal Code&rdquo; and &ldquo;Key Figures&rdquo;.</p>
<a class="text-orange-600" id="slide-39" href="#slide-39">
    # 39
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-39.png"
    alt="A response from ChatGPT with a structured summary of NYC bill Int. 601 that conforms to the structure given in the previous slide."
/>

</p>
<p>This prompt wasn&rsquo;t hard to write. It already works pretty well!</p>
<p>All our sections are there.</p>
<p>The &ldquo;Implications&rdquo; section gives me an easy overview as a NYC resident.</p>
<p>If I want to dig in further, I can do that by perusing the &ldquo;Law Requirements&rdquo; section.</p>
<p>If I just want to know about fines and dates, I can eyeball those at the bottom.</p>
<p>So&hellip; what&rsquo;s the problem with this prompt?</p>
<p>It will not do a good job, consistently, over a wide range of bills.</p>
<p>We have not yet accounted for any variations in how legislation is written.</p>
<p>And, even with similarly-written legislation, it may miss out on sections, hallucinate contents of the legal code, or fail to extract some dates and fines.</p>
<a class="text-orange-600" id="slide-40" href="#slide-40">
    # 40
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-40.png"
    alt="LESSON #2. Give examples. Want to improve a prompt fast? Provide 5-10 diverse examples.
"
/>

</p>
<p>If you want a prompt to work well, more consistently, over a wide range of inputs, the fastest way to do that is to <em>provide 5-10 diverse, illustrative examples.</em></p>
<p>Not only will your prompt be more effective after you are done with this exercise, the process <strong>will force you to carefully examine and handle real artifacts in your data</strong> (in our case, NYC bills).</p>
<p>Getting to know the contours of the data you are feeding an LLM is an extremely useful and underrated activity!</p>
<p>People don&rsquo;t talk about it enough because it is not &ldquo;one weird trick&rdquo;. It is tedious but effective.</p>
<p>Coming up with good examples is consistently the most impactful thing I do when I&rsquo;m improving a prompt.</p>
<a class="text-orange-600" id="slide-41" href="#slide-41">
    # 41
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-41.png"
    alt="SYSTEM (1/2) Your job is to summarize the NYC bill that the user provides. The audience is a NYC resident who wants to get up to
speed on the law, who is a layperson. The summary must contain these sections: Implications of the Bill This is 1-2 sentences describing the implications of the bill for people living in NYC. Law Requirements What the law will require if it is enacted, as a series of 1-sentence bullet points. References to Legal Code Sections of the NYC legal code it references, with an explanation of why: does it change the section, does it only refer
to it, or something else? Key Figures A bullet point list of key figures, especially any fines/penalties, durations, when the law takes effect, and dates lawmakers, agencies, and the public must adhere to. Explain these, one bullet point each."
/>

</p>
<p>When I&rsquo;m adding examples, this is what my prompt looks like.</p>
<p>First, I start with the prompt I already have.</p>
<p>This slide shows the prompt we just wrote for bill summarization.</p>
<p>I&rsquo;ve made no changes to it!</p>
<a class="text-orange-600" id="slide-42" href="#slide-42">
    # 42
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-42.png"
    alt="SYSTEM (2/2): # EXAMPLE 1 ## INPUT [Text from a bill] ## OUTPUT [Your example] &amp;hellip; # EXAMPLE N &amp;hellip;"
/>

</p>
<p>I add examples to the end of my prompt.</p>
<p>Each example gets a header and two sections: input and output.</p>
<p>I spend time crafting and adding 5-10 diverse, illustrative examples and append them to my prompt this way.</p>
<a class="text-orange-600" id="slide-43" href="#slide-43">
    # 43
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-43.png"
    alt="Use your existing prompt to craft examples. On the right is output of a summary for a NYC bill."
/>

</p>
<p>The best way to craft examples is to use your existing prompt as a starting point.</p>
<p>Pictured here is a summary for another bill using the prompt we already have.</p>
<p>(This bill introduces tiered fines for chain businesses in NYC when they fail to clear snow/debris from sidewalks.)</p>
<p>It&rsquo;s easier to edit and massage this summary into your desired output than it is to start from scratch.</p>
<p>Another reason to craft examples this way: you will examine lots of output and see <strong>exactly how your prompt fails to deliver</strong>. As you do this:</p>
<ol>
<li>Note every kind of failure you see.</li>
<li>Add directions and an example to address it.</li>
<li>Do this until your prompt works well enough, consistently enough.</li>
</ol>
<p>This is how you systematically improve a prompt.</p>
<a class="text-orange-600" id="slide-44" href="#slide-44">
    # 44
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-44.png"
    alt="Diverse examples for this prompt: Bills with fines/penalties. Bills with multiple fines/penalties for each offense/at different tiers. Very long bills. Bills with many references to the NYC legal code. Bills whose dates in effect depend on other bills (these exist!) Bills that repeal sections of the code (will need to update the prompt, too).
"
/>

</p>
<p>These are the kinds of examples I&rsquo;d work on based on my analysis of a bunch of bills.</p>
<p>These cases account for most of the major variations in how NYC legislation is written.</p>
<a class="text-orange-600" id="slide-45" href="#slide-45">
    # 45
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-45.png"
    alt="Try doing these yourself! Bill summarizer: Create the best bill summarizer you can. Bring in the NYC legal code: If the user provides relevant sections of the legal code after a summary’s been delivered, the chat should continue with an improved summary. Bill changes. Write a prompt that explains the implications of changes from one version of the law to the next (these are on Legistar). Add citations It’s important to verify answers! Include citations in any of the above to make that easier."
/>

</p>
<p>The best way to get good at that coaxing LLMs into doing useful things (also referred to as &ldquo;prompt engineering&rdquo;) is to do it.</p>
<p>Try these exercises yourself at home.</p>
<p>If you&rsquo;re using ChatGPT, you might not be able to override the system prompt, in which case you can paste your prompt as the first message.</p>
<p>I like using <a href="https://typingmind.com">TypingMind</a> for all my testing.</p>
<p>It&rsquo;s a cheap chatbot that provides a chat interface over all the major LLMs out there.</p>
<p>I can configure and save system prompts for various tasks, and I can quickly test all my prompts across models without writing any code.</p>
<a class="text-orange-600" id="slide-46" href="#slide-46">
    # 46
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-46.png"
    alt="LLMs vary by capability, cost,
and context window size. (&amp;hellip; and other things, but those are the dimensions in scope for this talk)
"
/>

</p>
<p>When you try those exercises, you might want to try out different language models too.</p>
<p>The current most-capable LLMs (as of late March 2024) are available via <a href="https://openai.com">OpenAI</a>, <a href="https://gemini.google.com/app">Google</a>, and <a href="https://www.anthropic.com/">Anthropic</a>.</p>
<p>&ldquo;Context window size&rdquo; refers to how much text an LLM can process at once.</p>
<p>Some LLMs can process text spanning a book (or multiple books). Some can only process a longer essay.</p>
<p>&ldquo;Cost&rdquo; just means&hellip; how much it costs to use a model.</p>
<a class="text-orange-600" id="slide-47" href="#slide-47">
    # 47
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-47.png"
    alt="Cost and context size are measured in tokens. There is a screenshot of the OpenAI tool available at this link: https://platform.openai.com/tokenizer"
/>

</p>
<p>Both cost and context window size are measured in &ldquo;tokens&rdquo;.</p>
<p>Tokens are <em>not</em>: words, characters, syllables, punctuation, or another neatly-organized category you might think of.</p>
<p>Tokens are how LLMs see text. When they are trained, they learn statistical correlations between tokens.</p>
<p>The screenshot above is from this tool: <a href="https://platform.openai.com/tokenizer">https://platform.openai.com/tokenizer</a>.</p>
<p>A good rule of thumb, if you&rsquo;re using OpenAI&rsquo;s models in English, is that a token is ~75% of a word.</p>
<a class="text-orange-600" id="slide-48" href="#slide-48">
    # 48
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-48.png"
    alt="A table of three of OpenAIs models, GPT-4 Turbo, GPT-4, and GPT-3.5. The table shows cost and context window size. The slide also states: My transcripts are 10K - 100K tokens, mean is 40-50K. citymeetings.nyc currently uses GPT-4 Turbo"
/>

</p>
<p>Here&rsquo;s a table comparing the cost and context window size for OpenAI&rsquo;s top 3 models (as of March 2024).</p>
<p>I use GPT-4 Turbo for <a href="https://citymeetings.nyc">citymeetings.nyc</a> because:</p>
<ul>
<li>It has a context window that fits long transcripts.</li>
<li>It hits a workable price-to-performance ratio for me.</li>
</ul>
<p>GPT-4 is OpenAI&rsquo;s most capable model at the moment (March 2024). This is reflected in its cost.</p>
<p>GPT-3.5 is OpenAI&rsquo;s least capable model. It is powerful for a wide range of tasks when prompted well and it costs basically nothing.</p>
<p>It costs more to generate &ldquo;output tokens&rdquo; than it does to process &ldquo;input tokens&rdquo;. Prices are commonly quoted &ldquo;per million tokens&rdquo;.</p>
<p>My transcripts are anywhere between 10K to 100K tokens, averaging about 40K to 50K tokens.</p>
<a class="text-orange-600" id="slide-49" href="#slide-49">
    # 49
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-49.png"
    alt="What I’ll talk about. 1. A tour of citymeetings.nycs user-facing and internal tools. 2. A crash course on writing an effective prompt. 3. How citymeetings.nyc creates video chapters. 4. Tips to approach more ambitious projects that use LLMSs. Section 1 and 2 is striked-out, section 3 is highlighted.
"
/>

</p>
<p>Now that I&rsquo;ve shared how I write my prompts, let&rsquo;s talk about how I arrived at the ones I use to create video chapters for <a href="https://citymeetings.nyc">citymeetings.nyc</a>.</p>
<a class="text-orange-600" id="slide-50" href="#slide-50">
    # 50
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-50.png"
    alt="CHAPTER EXTRACTION. 1. Identify speakers. Correctly-identified speakers make chapters better."
/>

</p>
<p>The first step in my &ldquo;chapter extraction&rdquo; process is to identify all the speakers in a transcript.</p>
<p>As you might recall, my transcripts are &ldquo;diarized&rdquo;. This means every word that is spoken is given a speaker label, like &ldquo;3&rdquo;.</p>
<p>In this step I map speaker labels to a specific name, role, and organization, like &ldquo;Martha Norrick, Chief Analytics Officer, Office of Technology and Innovation (OTI)&rdquo;.</p>
<p>Correctly-identified speakers lead to better chapters.</p>
<p>For example, if I want an LLM to identify chapters that begin with a question asked <em>by a council member</em>, it&rsquo;s important that it knows who is speaking.</p>
<a class="text-orange-600" id="slide-51" href="#slide-51">
    # 51
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-51.png"
    alt="There is a screenshot of a screen that shows a citymeetings.nyc meeting with a panel to edit speaker names, roles, and organizations."
/>

</p>
<p>Meetings usually have 20+ speakers, and many can get to 50-60 speakers.</p>
<p>I review all the speakers that my speaker identification process generates using the tool pictured here.</p>
<p>I try to do two things in my review:</p>
<ol>
<li>Ensure the name/role/organization are correct.</li>
<li>Fix spellings, even if they might be misspelled in the transcript.</li>
</ol>
<a class="text-orange-600" id="slide-52" href="#slide-52">
    # 52
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-52.png"
    alt="An LLM might be capable of processing 128K
tokens&amp;hellip; But that doesn’t mean it will be good at it."
/>

</p>
<p>My transcripts are 40K - 50K tokens on average. They fit comfortably in GPT-4 Turbo&rsquo;s context window.</p>
<p>All my first attempts to identify speakers used:</p>
<ol>
<li>A single prompt.</li>
<li>The entire transcript.</li>
</ol>
<p>These attempts failed!</p>
<p>It turns out that even if an LLM is capable of processing 128K tokens, that doesn&rsquo;t mean it will do it well.</p>
<p>Let&rsquo;s write a prompt to identify speakers this way. We&rsquo;ll look at some output and I&rsquo;ll talk about why this happens.</p>
<a class="text-orange-600" id="slide-53" href="#slide-53">
    # 53
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-53.png"
    alt="SYSTEM (1/3) Your job is to identify speakers in a NYC city council transcript. The user will provide one message containing the transcript. your reply must be a list of all speakers in the transcript with their name, role, and organization. The transcript will be formatted like this: [SPEAKER: 0] How was your weekend? [SPEAKER: 1] It was great! How was yours? [SPEAKER: 5] Sorry to interrupt, but I think we need to start the meeting. The numbers following “SPEAKER: “ are all unidentified speaker labels."
/>

</p>
<p>This prompt doesn&rsquo;t fit on a single slide. Here is part 1 of 3.</p>
<p>First, I write the high-level &ldquo;job&rdquo; that the language model is supposed to do.</p>
<p>Then I get into the details, starting with a description of how the transcript will be formatted.</p>
<a class="text-orange-600" id="slide-54" href="#slide-54">
    # 54
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-54.png"
    alt="SYSTEM (2/3) Your output must be a JSON list formatted like this: a JSON list with fields speaker_label, name, role, and organization. (The rest of the prompt has an example list with 3 JSON objects containing identified speakers)."
/>

</p>
<p>Here I&rsquo;m describing how I want the LLM to output identified speakers.</p>
<p>JSON is a machine-readable format that developers will be familiar with, but it&rsquo;s relatively human-readable too. This is what it looks like.</p>
<p>I&rsquo;m asking the LLM to give me four fields for every speaker in the transcript: speaker_label, name, role, and organization.</p>
<a class="text-orange-600" id="slide-55" href="#slide-55">
    # 55
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-55.png"
    alt="SYSTEM (3/3) The fields I want you to provide for each speaker are: “speaker_label”’: This is the speaker label for the speaker you are identifying. “name”: This is the name of the speaker. Should be UNKNOWN if you cannot infer the name. “role”: The speaker’s role. Should be UNKNOWN if you cannot infer the name. If they are a council member, this must be “Council Member”. If they are a clerk, sergeant-at-arms, committee counsel, or other council staff, this must be “Council Staff”. “organization”: The organization the speaker is a part of. If they are a Council Member or Council Staff, this must be “council member”. # TIPS - There may be transcription errors, like misspelled or mis-transcribed words. - The transcript diarization may be incorrect. - Roll calls are especially prone to diarization errors because they are short and done in quick succession. Often the same speaker will be identified for multiple roll calls. - Speakers often introduces themselves. If they do, you can assume their name and role is what they state in the introduction. If speakers don’t introduce themselves, other context clues include: - They might be introduced by the previous speaker - They might be thanked by the following speaker. - The speaker might is not always a council member. The speaker may be staff, like a clerk or sergeant-at-arms, or a NYC agency representative or member of the public giving testimony."
/>

</p>
<p>I wrap things up by detailing how I want the LLM to fill in each field for every speaker.</p>
<p>I also provide tips around how to infer speaker details and I share some &ldquo;gotchas&rdquo; around mistranscriptions and badly-diarized roll calls.</p>
<a class="text-orange-600" id="slide-56" href="#slide-56">
    # 56
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-56.png"
    alt="A JSON list of three identified speakers with fields speaker_label, name, role, and organization."
/>

</p>
<p>Here&rsquo;s some output from speakers in the &ldquo;middle&rdquo; of this meeting, which has close to 60 speakers.</p>
<p>The names for speaker labels 27 and 28 appear in the transcript.</p>
<p><em>All these responses are completely wrong.</em></p>
<a class="text-orange-600" id="slide-57" href="#slide-57">
    # 57
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-57.png"
    alt="A JSON list of three identified speakers with fields speaker_label, name, role, and organization. On the right, a screenshot of the correct names, roles, and organizations for the same speakers."
/>

</p>
<p>The correct answers are on the right.</p>
<p>The current speaker identification process used in <a href="https://citymeetings.nyc">citymeetings.nyc</a> gets these answers correct the first time.</p>
<p>None of the fields are &ldquo;UNKNOWN&rdquo;. They are all identifiable based on context in the transcript.</p>
<a class="text-orange-600" id="slide-58" href="#slide-58">
    # 58
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-58.png"
    alt="A JSON list of three identified speakers with fields speaker_label, name, role, and organization."
/>

</p>
<p>Here are three more responses from the prompt we just wrote.</p>
<p>All of these are also, unfortunately, completely wrong.</p>
<a class="text-orange-600" id="slide-59" href="#slide-59">
    # 59
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-59.png"
    alt="A JSON list of three identified speakers with fields speaker_label, name, role, and organization. On the right, a screenshot of the correct names, roles, and organizations for the same speakers."
/>

</p>
<p>The correct answers are to the right here.</p>
<p>Again, the current speaker identification process used in <a href="https://citymeetings.nyc">citymeetings.nyc</a> gets these answers correct the first time.</p>
<p>It is wildly obvious who a speaker is based on context from a transcript. I expected LLMs to do this well.</p>
<p>This felt like it should be a simple, tractable problem.</p>
<p>So, why does this happen?</p>
<a class="text-orange-600" id="slide-60" href="#slide-60">
    # 60
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-60.png"
    alt="The “lost-in-the-middle” problem."
/>

</p>
<p>One of the reasons LLMs struggle to do things accurately with long context windows is a well-documented issue called the <a href="https://arxiv.org/abs/2311.09198">&ldquo;lost-in-the-middle&rdquo; problem</a></p>
<p>LLMs tend to more heavily weight context at the beginning and end of their windows. They &ldquo;lose&rdquo; context in the middle and their accuracy suffers.</p>
<p>I deliberately showed you examples from the middle of my transcript. Anecdotally, speakers were identified more accurately at the beginning and end of the transcript.</p>
<p>In the middle, though? Completely wrong. Mostly <code>&quot;UNKNOWN&quot;</code>. It&rsquo;s like the LLM just gave up.</p>
<p>Folks are working on addressing the &ldquo;lost-in-the-middle&rdquo; problem.</p>
<p>Recent (March 2024) LLM entrants by Google (Gemini 1.5) and Anthropic (Claude Opus) have published impressive results showing their LLMs find specific pieces of information in the middle of very large contexts.</p>
<p>I have yet to see if these abilities translate well to performing speaker identification on transcripts. I&rsquo;ll report back when I do.</p>
<p>(If you want to know when that happens, you can <a href="https://x.com/voberoi">follow me on X</a> or subscribe to this blog to receive a newsletter when I post something new.)</p>
<a class="text-orange-600" id="slide-61" href="#slide-61">
    # 61
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-61.png"
    alt="Long transcripts leave little space for examples."
/>

</p>
<p>When I feed the LLM an entire 40-50K token transcript, I have very little space for examples in my prompt.</p>
<p>Even if the &ldquo;lost-in-the-middle&rdquo; problem is solved, this is a huge downside.</p>
<p>Maybe this will change at some point, but I&rsquo;ve found that adding a few diverse, illustrative examples is the fastest and easiest path for me to improve the performance and consistency of my prompt.</p>
<p>Unfortunately, a 50K-token transcript uses almost half of my budget for GPT-4 Turbo. I can fit, at most, one good example in my prompt.</p>
<p>I tried to compress my examples by saying things like &ldquo;here&rsquo;s a portion of a transcript, and how you might go about inferring speakers for it, here&rsquo;s the output&rdquo;, etc.</p>
<p>That didn&rsquo;t work, either.</p>
<a class="text-orange-600" id="slide-62" href="#slide-62">
    # 62
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-62.png"
    alt="Want better results over large bodies of text? “Chunk” it."
/>

</p>
<p>The solution to problems you encounter when processing large bodies of texts with LLMs is to break them into &ldquo;chunks&rdquo;.</p>
<p>There are many chunking strategies you can employ and they all have tradeoffs.</p>
<p>What you decide to do is highly dependent on your task and goals.</p>
<p>Here&rsquo;s a simple strategy to chunk a long transcript:</p>
<ol>
<li>Grab 8,000 tokens from the transcript.</li>
<li>Feed those tokens into the LLM.</li>
<li>Repeat 1 and 2 on the remainder of the transcript until the entire transcript has been exhausted.</li>
</ol>
<p>This approach is very easy to implement, which is a huge benefit!</p>
<p>But it leads to issues for speaker identification: what if speaker label 3 shows up in every chunk?</p>
<p>How do I reconcile the answers I get from each chunk? Which one is correct?</p>
<a class="text-orange-600" id="slide-63" href="#slide-63">
    # 63
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-63.png"
    alt="USER: IDENTIFY SPEAKER 7 &amp;ndash; START INSTANCE 1 FOR SPEAKER 7 &amp;ndash; [SPEAKER 1]: Im going to pass it over to Council Member Brewer, then Hudson. [SPEAKER 7]: Hi Id like to discuss&amp;hellip; [SPEAKER 1]: Thank you. Next up, Council Member Hudson. &amp;ndash; END INSTANCE 1 FOR SPEAKER 7 &amp;ndash;"
/>

</p>
<p>My strategy for speaker identification is to identify one speaker at a time.</p>
<p>If there are 60 speakers in a transcript, I will create 60 chunks and prompt the LLM 60 times.</p>
<p>Each chunk looks like the above.</p>
<p>In my message I first say which speaker label I want the LLM to identify.</p>
<p>Then I provide instances of that speaker in the transcript, with some neighboring context.</p>
<p>Each instance is:</p>
<ul>
<li>The speaker I&rsquo;m identifying, saying something&hellip;</li>
<li>&hellip; sandwiched in between the previous speaker and next speaker saying something.</li>
</ul>
<p>It is obvious based on &ldquo;INSTANCE 1 FOR SPEAKER 7&rdquo; in the chunk above that speaker 7 is Council Member Gale Brewer.</p>
<p>This strategy works: <em>somewhere</em> in the instances I provide there is usually one instance that makes it obvious who the speaker is.</p>
<a class="text-orange-600" id="slide-64" href="#slide-64">
    # 64
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-64.png"
    alt="Want the LLM to reason? Tell it to think!"
/>

</p>
<p>For the LLM to identify speakers correctly, I need it to reason.</p>
<p>The LLM needs to systematically look at every instance and identify supporting evidence for the speaker&rsquo;s name, role, and organization.</p>
<p>It also needs to dodge false positives suggested by mistranscriptions and instances where the speaker diarization is wrong.</p>
<p>If you want an LLM to reason, it turns out that <em>telling it to think works quite well.</em></p>
<p>This is a technique called &ldquo;chain-of-thought&rdquo;.</p>
<a class="text-orange-600" id="slide-65" href="#slide-65">
    # 65
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-65.png"
    alt="A screenshot of a tool I use to evaluate speaker identification responses. Highlighted on the left is a panel showing the speaker identification chunk for the speaker Im identifying."
/>

</p>
<p>This is a screenshot of a tool I use to evaluate my speaker identification responses.</p>
<p>(Evaluation is critical! More on that soon.)</p>
<p>Highlighted here on the left is the input: a chunk with instances for SPEAKER 6 in a transcript.</p>
<a class="text-orange-600" id="slide-66" href="#slide-66">
    # 66
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-66.png"
    alt="A screenshot of a tool I use to evaluate speaker identification responses. Highlighted on the right is a panel showing the response from the LLM."
/>

</p>
<p>Highlighted here on the right is the output I get from GPT-4 Turbo when identifying SPEAKER 6.</p>
<p>The prompt I use is available on the next slide.</p>
<p>In it, I instruct the LLM to output three sections structured exactly as shown here with headers/footers that look like <code>!!!=START/END &lt;SECTION&gt;=!!!</code></p>
<p>In the &ldquo;INTERNAL THINKING&rdquo; section I&rsquo;ve instructed the LLM to &ldquo;detail your step-by-step thinking to identify who the speaker is, including their name, role and organization.&rdquo;</p>
<p>In this screenshot, the LLM repeatedly surfaces up evidence that SPEAKER 6 is Council Member Adrienne Adams. That is the correct answer here.</p>
<p>My results are consistently, measurably better when I use chain-of-thought for speaker identification, <em>but that it is not its only benefit.</em></p>
<p>Sometimes the LLM&rsquo;s reasoning gets things subtly wrong or goes <em>totally off the rails</em>.</p>
<p>When it does, I get to observe exactly how. I can then address issues in its reasoning with new directions or, even better, a new example.</p>
<p>I&rsquo;ve been able to systematically eliminate entire classes of problems this way.</p>
<a class="text-orange-600" id="slide-67" href="#slide-67">
    # 67
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-67.png"
    alt="My prompt is here: https://gist.github.com/voberoi/3d82f6b2a55e79b7cd014847853be8bf. ~8K tokens long. Specific directions, many examples. Uses “chain-of-thought”."
/>

</p>
<p><a href="https://gist.github.com/voberoi/3d82f6b2a55e79b7cd014847853be8bf">My prompt is available here.</a></p>
<p>It is almost 8K tokens in length.</p>
<p>I&rsquo;ve followed every tip I lay out in this presentation to get it working well.</p>
<p>Getting here took me ~2 weeks. It is time-consuming to get a prompt to work well, consistently, over a wide range of inputs.</p>
<p>There are a lot of things to try at first. Creativity helps.</p>
<p>Then, when you start hitting walls, you need to roll up your sleeves and <em>really dig in</em> to figure out why your prompt isn&rsquo;t working.</p>
<p>My biggest learning working on <a href="https://citymeetings.nyc">citymeetings.nyc</a> is that it is <em>critical</em> to have some means to evaluate if your prompt is &ldquo;working&rdquo;.</p>
<p><strong>If you don&rsquo;t systematically evaluate the performance of your prompts, you will find it extremely challenging to improve them.</strong></p>
<a class="text-orange-600" id="slide-68" href="#slide-68">
    # 68
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-68.png"
    alt="A screenshot of a tool to systematically evaluate output from a prompt. On the left is a list of numbers from 0 through 8. Then there are two panels. The left panel shows input to a prompt, the right panel shows output. There are checkboxes for format, name, role, and organization that the user can check."
/>

</p>
<p>Here&rsquo;s the tool I use to evaluate speaker identification responses.</p>
<p>My partner at <a href="https://baxterhq.com">Baxter</a> hacked it together in an afternoon.</p>
<p>This tool allowed me to evaluate and improve my prompt systematically.</p>
<p>Every time I made a change, I&rsquo;d run it on multiple meetings and speakers.</p>
<p>Then I&rsquo;d pop into this tool, evaluate all the results, and note all the ways the LLM failed.</p>
<p>I&rsquo;d then update the prompt to eliminate common classes of failures I observed.</p>
<a class="text-orange-600" id="slide-69" href="#slide-69">
    # 69
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-69.png"
    alt="Screenshot of a tweet by Vikram Oberoi (@voberoi) Tuesday’s accuracy was bad — on the order of 35-50%. Wednesday I got to 65-75%. Today I’m getting 80-90%. All via segmenting transcripts cleverly, better prompting, and more diverse examples in the prompt. In the process I have created 200 examples for a fine tuning dataset.
"
/>

</p>
<p>I kept doing this until I went from ~35% to 80-90% speaker identification accuracy.</p>
<p>This process is tedious. <em>But it is easy and works reliably</em>.</p>
<a class="text-orange-600" id="slide-70" href="#slide-70">
    # 70
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-70.png"
    alt="Why you should regularly and
systematically evaluate your LLM
results. There is a cartoon of a robot holding a skull. The robot looks like it is from Shakespearean times. He is saying: Alas, poor Yorick! My facial recognition technology would know you anywhere!"
/>

</p>
<p>I wrote <a href="https://vikramoberoi.com/why-you-should-regularly-and-systematically-evaluate-your-llm-results/">a whole blog post about why you should regularly and systematically evaluate your LLM results</a></p>
<p>This is how you get a prompt to work well, consistently, over a wide range of inputs.</p>
<p>In my post, I share failures I observed while evaluating my speaker identification prompt + solutions I implemented to address them.</p>
<a class="text-orange-600" id="slide-71" href="#slide-71">
    # 71
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-71.png"
    alt="The LLM responses would heavily weight self-introductions, like Im council member
Brooks-Powers., and fail to draw conclusions from sentences like Id like to pass the
mic to council member Restler. Solution: I added directions and examples showing how to identify a speaker based on neighboring context.
"
/>

</p>
<p>Here&rsquo;s a really basic failure I observed early on.</p>
<p>The LLM would often fail to make accurate inferences based on <strong>incredibly obvious</strong> neighboring context.</p>
<p>So I added directions and an example showing a correct inference from neighboring context in my prompt.</p>
<p>Doing that eliminated <em>this entire class of failure.</em> I have yet to see this issue come up again.</p>
<a class="text-orange-600" id="slide-72" href="#slide-72">
    # 72
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-72.png"
    alt="Mistranscriptions would cause issues: Jose instead of Ossé, Adrian instead of Adrienne. Solution: I provided a list of council member names and added directions to infer mistranscriptions. The LLM starting figuring out when a name was a likely mistranscription and got many more inferences correct."
/>

</p>
<p>Another failure I observed is that common mistranscriptions of council members&rsquo; names would cause issues.</p>
<p>So I gave the LLM additional context: a list of council member names.</p>
<p>Then I added directions to infer mistranscriptions for council members.</p>
<p>I also added an example showing the LLM correctly identifying a council member despite a mistranscription.</p>
<p>It worked. Another class of error, gone.</p>
<p>Following a process like this systematically and iteratively is a reliable way to improve your prompt.</p>
<a class="text-orange-600" id="slide-73" href="#slide-73">
    # 73
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-73.png"
    alt="CHAPTER EXTRACTION 2. Creating chapters. My current solution has 3 distinct steps: a “chain” of prompts."
/>

</p>
<p>After I identify speakers, I extract chapters.</p>
<p>My current approach to chapter extraction has three distinct steps with different prompts.</p>
<p>This lets the LLM do one thing at a time, which is necessary for more complex tasks.</p>
<p>This technique is known as &ldquo;chaining&rdquo; prompts together such that output from one prompt becomes input for the next.</p>
<a class="text-orange-600" id="slide-74" href="#slide-74">
    # 74
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-74.png"
    alt="What makes a useful chapter? I manually created ~300 chapters over several meetings to figure this out."
/>

</p>
<p>All my initial attempts to create chapters with a single prompt failed.</p>
<p>My prompts detailed what I thought a good chapter would be and I provided examples.</p>
<p>Here&rsquo;s an excerpt from one of these early attemps:</p>
<pre tabindex="0"><code>Good chapters are sections of video that:

- Focus on a monologue or on a single topic.
- Focus on a conversational exchange on a single topic.
- People viewing the video can skip to in order to find the information they are looking for.
- People viewing the video can share with others to highlight a specific topic, exchange,
  or testimony.

Chapters may cover a single speaker, for example when a council member is introducing a meeting, bill, or giving a speech.

Chapters may cover multiple speakers, for example when a council member is asking questions of a speaker giving testimony.

An exchange between multiple speakers may be a single chapter, or it may be multiple speakers. Make sure to break down
long exchanges into multiple chapters if they cover multiple sub topics.
</code></pre><p>This prompt is <em>too vague</em>. I&rsquo;d get chapters that were not useful but I couldn&rsquo;t clearly articulate why and what I wanted instead!</p>
<p>Some were too short, some were too long. My results were inconsistent.</p>
<p>So I took a step back to define, in more concrete terms, what a useful <a href="https://citymeetings.nyc">citymeetings.nyc</a> chapter is.</p>
<p>To do this, I built a tool to manually create chapters. I created ~300 of them across many meetings.</p>
<a class="text-orange-600" id="slide-75" href="#slide-75">
    # 75
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-75.png"
    alt="A screenshot of a citymeetings.nyc tool to create chapters for a meeting. There is a panel showing the first and last sentence in a chapter. The transcript is highlighted, with the user selecting the chapter boundaries. There is a button that says Generate with AI"
/>

</p>
<p>This is the tool I use to manually create chapters.</p>
<p>I select the start and end boundaries by clicking on sentences in the transcript.</p>
<p>Then I click on &ldquo;Generate with AI&rdquo; to save the chapter boundaries and generate a title and description for it.</p>
<a class="text-orange-600" id="slide-76" href="#slide-76">
    # 76
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-76.png"
    alt="A good citymeetings.nyc chapter starts at the beginning of: a council member’s question, an individual’s testimony, standalone remarks by a council member, a procedural section.
"
/>

</p>
<p>A good <a href="https://citymeetings.nyc">citymeetings.nyc</a> chapter starts at the beginning of a <code>QUESTION</code>, an individual&rsquo;s <code>TESTIMONY</code>, standalone <code>REMARKS</code> by a council member, or a section dedicated to a <code>PROCEDURE</code>.</p>
<p>These are how chapters are tagged on <a href="https://citymeetings.nyc">citymeetings.nyc</a> today.</p>
<a class="text-orange-600" id="slide-77" href="#slide-77">
    # 77
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-77.png"
    alt="A good citymeetings.nyc chapter ends at the conclusion of&amp;hellip;
&amp;hellip; an exchange answering a question.
&amp;hellip; an individual’s testimony.
&amp;hellip; Sstandalone remarks by a council member.
&amp;hellip; a procedural section.
"
/>

</p>
<p>A good <a href="https://citymeetings.nyc">citymeetings.nyc</a> ends at the conclusion of a <code>QUESTION</code>, <code>TESTIMONY</code>, <code>REMARKS</code>, or <code>PROCEDURE</code>.</p>
<p>Questions are a bit special.</p>
<p>I want a question chapter to end after an <em>exchange</em> that answers the question, and not after the initial response given by an agency.</p>
<a class="text-orange-600" id="slide-78" href="#slide-78">
    # 78
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-78.png"
    alt="A good citymeetings.nyc chapter has a useful title and description. Questions: the title is phrased as a question. The description answers it
based on the transcript and mentions who answered it. Testimonies  Remarks: the title is [Speaker] on [Topic]. The description summarizes the key points of the testimony. Procedure: the title is basic. The description is one sentence. Both describe the procedure as quickly as possible."
/>

</p>
<p>Finally, a good <a href="https://citymeetings.nyc">citymeetings.nyc</a> chapter has a useful title and description.</p>
<p>These differ by chapter type. I do a bit of &ldquo;editorial work&rdquo; in these prompts.</p>
<a class="text-orange-600" id="slide-79" href="#slide-79">
    # 79
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-79.png"
    alt="Chapters start at transcript markers. 1. Extract transcript markers. There is a JSON list of JSON objects that have fields: type, time_marker, and marker_information."
/>

</p>
<p>In step 1, my prompt grabs &ldquo;transcript markers&rdquo;, which mark the beginning of:</p>
<ul>
<li>A question asked by a council member. (<code>QUESTION</code>)</li>
<li>Testimony by an agency or a member of the public. (<code>TESTIMONY</code>)</li>
<li>Standalone remarks by a council member. (<code>REMARKS</code>)</li>
<li>A procedural section. (<code>PROCEDURE</code>)</li>
</ul>
<p>I don&rsquo;t say <em>anything</em> about chapters at this stage.</p>
<p>I&rsquo;ve found I get bad results if I say something to the effect of &ldquo;This is the beginning of a chapter in a video encompassing standalone remarks by a council member.&rdquo;</p>
<p>I want the LLM to do exactly one thing: find me all the beginnings of one the four items I listed above, not the &ldquo;start of a potential chapter&rdquo;, which is too vague.</p>
<p>The chunking strategy I use is to process 8K tokens at a time until I exhaust the transcript.</p>
<p>And, in my transcript, I provide something I call &ldquo;time markers&rdquo; instead of timestamps.</p>
<a class="text-orange-600" id="slide-80" href="#slide-80">
    # 80
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-80.png"
    alt="[SPEAKER: Dennis Walcott, President, Queens Public Library] [T459] So several things. [T460] 1 with the capital process, the pig could be just totally restored, and that would basically address our needs. [T461] In addition to that, as Linda and others have indicated, Tony has indicated, that the push out to 33 just stops
projects dead in their tracks and will not be funding for us to kick projects off that weve been planning for for a long period of time.[T462] So the various districts throughout the city will be impacted in having buildings that are potentially closed as a result of infrastructure problems.[T463] Part of the balancing act that we always do is the capital eligible capital projects versus those capital
projects that may not be capital eligible and then having expense money to pay for that. [T464] Now well suffer on both sides of the coin and that will have fewer capital dollars available and then well have fewer expense dollars and that will double impact the capital infrastructure needs of our buildings. [SPEAKER: Linda Johnson, President, Brooklyn Public Library] [T465] In addition, as weve all testified, our systems are aging. [T466] We all have libraries of different from different eras. [T467] The Carnegie library is perhaps the most cherished just because of their historical against are also the most expensive to maintain. [T468] And weve been told by OMB that we should not be forecasting new capital projects. [T469] But at the same time, we have $13,800,000 in new needs. [T470] And those needs get more expensive as time passes. [T471] Obviously, the sooner you can correct an issue, the less cost it is to do the renovation. [T472] So in the case of projects where weve already designed the work and were ready to go, but weve been"
/>

</p>
<p>Here&rsquo;s what a transcript looks like when I pass it to any of these steps.</p>
<p>I provide markers that start with &ldquo;T&rdquo; followed by an integer to mark the beginning of every sentence. These are &ldquo;time markers&rdquo;.</p>
<p>I initilly used timestamps (like &ldquo;00:23:42&rdquo;), but the LLM would regularly hallucinate timestamps that weren&rsquo;t in my transcript.</p>
<p>This made the output useless, which is why I use my made-up &ldquo;time markers&rdquo;.</p>
<p>This is a common trick I use to great effect: if the LLM frequently hallucinates something one might commonly find in its training data (like timestamps), I&rsquo;ll make up my own &ldquo;language&rdquo; for it to follow instead.</p>
<p>I map time markers back to specific timestamps in my transcript and video.</p>
<a class="text-orange-600" id="slide-81" href="#slide-81">
    # 81
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-81.png"
    alt="2. Create Chapters Determine end of QUESTION
QUESTION. Determine end of TESTIMONY. Determine end of REMARKS. Determine end of PROCEDURE."
/>

</p>
<p>In step 2, I create chapters from transcript markers, one transcript marker at a time.</p>
<p>The input to this prompt is:</p>
<ul>
<li>A transcript marker.</li>
<li>The portion of the transcript that encompasses the duration between this transcript marker and the next one.</li>
</ul>
<p>Somewhere in that portion of the transcript is the end of a chapter that starts with that transcript marker.</p>
<p>I use a different prompt with different directions to determine the end of each chapter.</p>
<a class="text-orange-600" id="slide-82" href="#slide-82">
    # 82
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-82.png"
    alt="3. Write Titles/Descriptions. INPUTS: Chapter Type, Chapter Start, Chapter End, Marker Information, Transcript. Write QUESTION title/description. Write TESTIMONY title/description. Write REMARKS title/description. Write PROCEDURE title/description."
/>

</p>
<p>Now that I have chapters, I write titles and descriptions for each.</p>
<p>I do this one chapter at a time, and I have a different prompt for each chapter type.</p>
<a class="text-orange-600" id="slide-83" href="#slide-83">
    # 83
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-83.png"
    alt="My prompts* are here: https://gist.github.com/voberoi/cfeb935b163c150eee5d7c86e7fb4337. *Each prompt is code that uses instructor, a Python package. I have not had time to iterate on this systematically. It works reasonably well!"
/>

</p>
<p><a href="https://gist.github.com/voberoi/cfeb935b163c150eee5d7c86e7fb4337">Here are my prompts.</a></p>
<p>A couple of caveats:</p>
<ol>
<li>I now use <a href="https://python.useinstructor.com/">instructor</a> instead of plain-English prompts.</li>
<li>I have not yet iterated on these prompts systematically in the same way I have my speaker identification prompts.</li>
</ol>
<p>Instructor is a Python package that makes it easier to get structured output more reliably from LLMs.</p>
<p><strong>If you are a non-developer, these prompts are still fairly legible:</strong> there&rsquo;s a bunch of plain old English mixed in with code. All that ends up in the system prompt.</p>
<p>These prompts are basic. I haven&rsquo;t even added good examples yet and they work reasonably well!</p>
<p>I spend 10-30 minutes reviewing and fixing chapters for meetings before publishing them.</p>
<p>This can still take a <em>ton</em> of time on busy weeks with long hearings, and I&rsquo;m working on bringing this time down.</p>
<p>It&rsquo;s currently budget hearing season in NYC (March 2024). So we&rsquo;ve been having ~20 hearings weekly, many of which are 5-10 hours long.</p>
<p>(This is why <a href="https://citymeetings.nyc">citymeetings.nyc</a> is not currently caught up on 2024&rsquo;s meetings.)</p>
<a class="text-orange-600" id="slide-84" href="#slide-84">
    # 84
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-84.png"
    alt="~$5 - $10 to identify speakers and extract chapters for one meeting without any optimization."
/>

</p>
<p>It costs me ~$5 - $10 to identify speakers and extract chapters for one meeting.</p>
<p>This is an <em>absolute bargain</em> for the public. At ~500 meetings a year, that&rsquo;s at most $5,000.</p>
<p>As a point of comparison, the NYC council pays $50K annually for <a href="https://nyc.legistar.com">Legistar</a>. I know this because I have the contracts from a <a href="https://a860-openrecords.nyc.gov/">FOIL request</a></p>
<p>I haven&rsquo;t even attempted to do this work more cheaply. This is a baseline, and I&rsquo;m confident I can bring costs down.</p>
<a class="text-orange-600" id="slide-85" href="#slide-85">
    # 85
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-85.png"
    alt="Some ways to lower costs Use different models. Not all tasks require GPT-4 Turbo. Fine-tune a model. I have thousands of examples to train on. Attempt fewer passes. Do more with one prompt (w/ potential loss in quality) Wait! Costs are falling fast."
/>

</p>
<p>Here are some ways I might lower costs.</p>
<ol>
<li>I currently use GPT-4 Turbo for every task. I don&rsquo;t have to. Other, cheaper models are up to some of the tasks I use GPT-4 Turbo to perform.</li>
<li>With hundreds of identified speakers and thousands of extracted chapters, I have enough data to start fine-tuning a less capable model. I&rsquo;ll pay some cost upfront for training, but operating a fine-tuned model is cheaper.</li>
<li>I can optimize my chapter extraction process by doing it in fewer passes. This is tricky: it might lower chapter quality.</li>
<li>I can wait. LLM costs are falling precipitously.</li>
</ol>
<p>If costs fall at the same rate they&rsquo;ve been falling this last year, simply waiting will make my costs manageable.</p>
<p>In fact, I&rsquo;m fairly confident that it will.</p>
<p>Last March (2023) when I started running initial tests, GPT-4&rsquo;s pricing was higher than I was willing to pay out of my own pocket for a project like <a href="https://citymeetings.nyc">citymeetings.nyc</a>.</p>
<p>8 months later, in November 2023, OpenAI released GPT-4 Turbo. It performs well and costs way less.</p>
<p>In the time between working on this presentation and publishing this post (a ~3 week window), new models have been released that are said to rival GPT-4, <em>and they cost less!</em></p>
<p>All this has happened in the span of one year.</p>
<p>I haven&rsquo;t touched fine-tuning (#2) yet, but I&rsquo;m very eager to do so and I&rsquo;ll report back when I do.</p>
<a class="text-orange-600" id="slide-86" href="#slide-86">
    # 86
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-86.png"
    alt="What I’ll talk about. 1. A tour of citymeetings.nycs user-facing and internal tools. 2. A crash course on writing an effective prompt. 3. How citymeetings.nyc creates video chapters. 4. Tips to approach more ambitious projects that use LLMSs. Sections 1, 2, and 3 are striked-out, section 4 is highlighted.
"
/>

</p>
<p>To wrap things up, I want to share a few tips for how to approach a more ambitious project that uses LLMs.</p>
<a class="text-orange-600" id="slide-87" href="#slide-87">
    # 87
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-87.png"
    alt="Start simply. Get one prompt to do one useful thing well enough.
"
/>

</p>
<p>Start by getting one prompt to do one useful thing well enough, consistently.</p>
<p>Doing that will expose you to the entire spectrum of things must do to deploy LLMs effectively in your product or service:</p>
<ol>
<li>You&rsquo;ll learn how to write an effective prompt.</li>
<li>You&rsquo;ll learn how to evaluate results and iterate on a prompt.</li>
<li>You&rsquo;ll figure out if the thing you&rsquo;re asking the LLM to do is valuable to someone.</li>
<li>You&rsquo;ll learn how to handle issues that arise when operating a product/service/feature that relies on LLMs.</li>
</ol>
<p>Doing all this well for one prompt is difficult enough.</p>
<p>Start simply!</p>
<a class="text-orange-600" id="slide-88" href="#slide-88">
    # 88
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-88.png"
    alt="Set up ways to systematically
evaluate your prompts early. This will help you iterate quickly.
"
/>

</p>
<p>It is impossible to iterate on prompts quickly or effectively unless you systematically evaluate them.</p>
<p>Without systems to do this, you won&rsquo;t know if a change you make is working, causing issues, or doing nothing.</p>
<p>Set up systems to do this early if you can. A good time to do this is when you&rsquo;ve figured out your basic approach to solving your problem.</p>
<p>In my case, it took me a while to figure out an approach to chapter extraction that worked&hellip; at all!</p>
<p>Now that I have an approach that&rsquo;s working reasonably well, it is time to evaluate and systematically improve it.</p>
<a class="text-orange-600" id="slide-89" href="#slide-89">
    # 89
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-89.png"
    alt="Expect prompts to give bad responses sometimes. Decide they don’t matter, handle them, or give users an out."
/>

</p>
<p>You must acknowledge that LLMs will fabricate things if you have any hope of deploying them effectively in your product or service.</p>
<p>LLMs <em>will</em> give you bad answers sometimes: how much does that matter for your use case, and what do you plan to do about it?</p>
<p>This is inherently a product design problem. Here are, broadly, the options you have:</p>
<ol>
<li><strong>Ignore bad answers.</strong> If an LLM outputting a &ldquo;bad&rdquo; answer doesn&rsquo;t matter for your use case, you can do this. (Consider yourself lucky!)</li>
<li><strong>Handle bad answers.</strong> More likely than not, you will need to handle bad answers. For <a href="https://citymeetings.nyc">citymeetings.nyc</a>, I do this by reviewing identified speakers and extracted chapters manually.</li>
<li><strong>Give users an out.</strong> You can also give users a way to address or side-step bad answers themselves. For <a href="https://citymeetings.nyc">citymeetings.nyc</a>, if a chapter title/summary is inaccurate, the video and transcript are right there for the user to double-check them. If the chapter boundaries aren&rsquo;t quite right, they can scroll through the transcript and seek to a nearby timestamp.</li>
</ol>
<p>You can&rsquo;t wish away hallucinations. Plan for them.</p>
<a class="text-orange-600" id="slide-90" href="#slide-90">
    # 90
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-90.png"
    alt="Optimize for cost later. Get a baseline and use better models to give you training data."
/>

</p>
<p>My final tip: optimize for cost later if you can.</p>
<p>It easier to get good results with more costly LLMs.</p>
<p>Use more costly, capable models to get good results, gain some momentum, validate an idea, etc.</p>
<p>Get a baseline cost, then optimize: there are <em>so many</em> ways to lower costs.</p>
<a class="text-orange-600" id="slide-91" href="#slide-91">
    # 91
</a>


<p>
<img
    class="mx-auto drop-shadow"
    src="images/slide-91.png"
    alt="Links and Resources: https://citymeetings.nyc/ nyc-school-of-data-2024.
"
/>

</p>
<p>That concludes my talk!</p>
<p>If this was helpful to you, please share it with your colleagues, on socials, at a meetup, etc.</p>
<p><a href="https://citymeetings.nyc/nyc-school-of-data-2024">Here is a page with links and resources related to this talk.</a> Links to my prompts are there, too.</p>
<p>Follow me:</p>
<ul>
<li><a href="https://x.com/voberoi">X/Twitter</a></li>
<li><a href="https://www.threads.net/@vikramo">Threads</a></li>
<li><a href="https://www.linkedin.com/in/voberoi/">LinkedIn</a></li>
</ul>
<p>Email me at <a href="mailto:vikram@citymeetings.nyc">vikram@citymeetings.nyc</a>.</p>
<p>At the conference I had a bowl of these fun pins that my wife, <a href="https://katiebcartoons.com">katiebcartoons</a>, made.</p>
<p>If you live in NYC and want one &ndash; I&rsquo;ll bring them to any related meetups/talks or if we happen to grab coffee!</p>
<p><em>Thanks to Jeremy Singer-Vine, Cameron Yick, and Alex Quinlan for providing extensive feedback on early drafts of this post.</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Why you should regularly and systematically evaluate your LLM results</title>
      <link>https://vikramoberoi.com/posts/why-you-should-regularly-and-systematically-evaluate-your-llm-results/</link>
      <pubDate>Sat, 03 Feb 2024 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/why-you-should-regularly-and-systematically-evaluate-your-llm-results/</guid>
      <description>This robot would have known this is just a random skull had anyone spent time evaluating results from the Yorick Identification Model. Credit: katiebcartoons. Evaluating results from LLM pipelines is time-consuming and often I&amp;rsquo;d rather poke my eye with stick instead of looking at another 100 results to decide if they are &amp;ldquo;good&amp;rdquo;.&#xA;But I do it anyway and I cannot understate how beneficial it is.&#xA;Here&amp;rsquo;s why you should do it:</description>
      <content:encoded><![CDATA[<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/alas_poor_yorick.png"
        alt="In this play on Shakespeare&amp;rsquo;s Hamlet, we see Hamlet played by a robot, holding up Yorick&amp;rsquo;s skull. He says, &amp;ldquo;Alas, poor Yorick! My facial recognition technology would know him anywhere!&amp;rdquo; "
        title="This robot would have known this is just a random skull had anyone spent time evaluating results from the Yorick Identification Model. Credit: katiebcartoons."
    />
    <figcaption class="text-center">This robot would have known this is just a random skull had anyone spent time evaluating results from the Yorick Identification Model. Credit: <a href="https://katiebcartoons.com">katiebcartoons</a>.</figcaption>
</figure>

</p>
<p>Evaluating results from LLM pipelines is time-consuming and often I&rsquo;d rather poke my eye with stick instead of looking at another 100 results to decide if they are &ldquo;good&rdquo;.</p>
<p>But I do it anyway <strong>and I cannot understate how beneficial it is.</strong></p>
<p>Here&rsquo;s why you should do it:</p>
<ol>
<li>You&rsquo;ll know when your changes are working.</li>
<li>You&rsquo;ll improve your LLM pipelines faster.</li>
<li>You&rsquo;ll build a fine-tuning dataset along the way.</li>
<li>You&rsquo;ll build better intuition for what does and doesn&rsquo;t work well with LLMs.</li>
</ol>
<p>These benefits compound if you evaluate LLM results regularly and systematically.</p>
<p>By <em>regularly</em>, I mean every time you make a change to your LLM pipeline. A change could be a new prompt, chunking strategy, model configuration, etc.</p>
<p>By <em>systematically</em> I mean that you carefully examine your results, capture how good or bad they are, and why.</p>
<p>If you are not already doing this, picture me grabbing you by the shoulders and screaming &ldquo;WHY NOT?!!&rdquo;</p>
<p>Then read the rest of this post, which is a more constructive attempt at persuading you to evaluate your LLM responses.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="https://pbs.twimg.com/media/GBwHLbNXQAA6dpk?format=jpg&amp;name=4096x4096"
        alt="A screenshot of prompt input and output evaluation tool."
        title="This evaluation tool took an afternoon to cobble together and it was game changing. Shown here is an early attempt at identifying speakers in city council meeting transcripts."
    />
    <figcaption class="text-center">This evaluation tool took an afternoon to cobble together and it was game changing. Shown here is an early attempt at identifying speakers in city council meeting transcripts.</figcaption>
</figure>

</p>
<p>In the course of building the <a href="https://vikramoberoi.com/a-ux-centric-approach-to-navigating-city-council-hearings-with-llms/">city council meeting tool described here</a>, I spent many days trying to get <code>gpt-4-turbo</code> to accurately identify speakers in 4-hour meeting transcripts with upwards of 50 speakers.</p>
<p>I started with a simple approach: feed the entire transcript into <code>gpt-4-turbo</code> with a prompt. The problem is that <code>gpt-4-turbo</code> performs tasks quite poorly on long context chunks (~40K - 100K tokens). <a href="https://vikramoberoi.com/a-ux-centric-approach-to-navigating-city-council-hearings-with-llms/">I talk briefly about the issue in this post.</a></p>
<p>So I pursued a new strategy: slice my transcript into manageable chunks and try identifying speakers in each chunk instead.</p>
<p>I spent a day or two feeling around in the dark for the right chunking strategy and prompt without getting great results. I eventually asked <a href="https://garrido.io">Gabriel</a> (a partner I work with from time to time) if he&rsquo;d be willing to cobble together a UI to help me evaluate my results quickly.</p>
<p>It took a thirty minute conversation, an afternoon of his time, and it was a complete game-changer for me.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/image-1.png"
        alt="A screenshot of a tweet by the author."
        title="A revelation! Here is a link to the tweet."
    />
    <figcaption class="text-center">A revelation! <a href="https://x.com/voberoi/status/1737993435225665567?s=20">Here is a link to the tweet.</a></figcaption>
</figure>

</p>
<p>I improved the performance of my approach dramatically over the course of a couple of days: my speaker identification accuracy went from 35-50% on Tuesday to 80-90% on Thursday.</p>
<p>To get to that point, I evaluated hundreds of responses and changed my prompt and chunking strategy to address specific classes of problems I observed.</p>
<p>Here are some problems I observed and changes I made to address them that improved speaker identification accuracy:</p>
<ul>
<li>The LLM responses would heavily weight self-introductions, like &ldquo;I&rsquo;m council member Brooks-Powers.&rdquo;, and fail to draw conclusions from sentences like &ldquo;I&rsquo;d like to pass the mic to council member Restler.&rdquo;
<ul>
<li><strong>Solution:</strong> I added directions and examples showing how to identify a speaker based on neighboring context.</li>
</ul>
</li>
<li>Mistranscriptions would cause issues: &ldquo;Jose&rdquo; instead of &ldquo;Ossé&rdquo;, &ldquo;Adrian&rdquo; instead of &ldquo;Adrienne&rdquo;.
<ul>
<li><strong>Solution:</strong> I provided a list of council member names and added directions to infer mistranscriptions. The LLM starting figuring out when a name was a likely mistranscription and got many more inferences correct.</li>
<li>(If you&rsquo;re interested in learning about other ways to handle mistranscriptions, <a href="https://vikramoberoi.com/using-metaphone-to-handle-bad-transcriptions-in-wine-voice-search-for-sommeliers/">I wrote this post about a voice search use case involving an Italian restaurant and sommeliers.</a>)</li>
</ul>
</li>
<li>Because roll calls happen so fast, <a href="https://en.wikipedia.org/wiki/Speaker_diarisation">speaker diarization</a> (identifying distinct speakers in audio and giving them a label) fails badly. As such, it is not reliable to infer a speaker&rsquo;s identity based on them saying &ldquo;Present&rdquo; in a roll call. (The image showing the eval tool above shows an example of this problem.)
<ul>
<li><strong>Solution:</strong> I added examples to deter the LLM from doing this in my prompt.</li>
</ul>
</li>
<li>In a few cases, the LLM hallucinated a council member&rsquo;s district and got the wrong answer: &ldquo;Adams is part of District X but the speaker said District Y, so I must infer that it is not Adams.&rdquo;, when actually it was Adams.
<ul>
<li><strong>Solution:</strong> I added the district and borough to my council member list.</li>
</ul>
</li>
<li>I noted that my chunks didn&rsquo;t always have the context required to identify a speaker.
<ul>
<li><strong>Solution:</strong> I modified my chunking strategy until they did.</li>
</ul>
</li>
</ul>
<p>I did this over and over again for many classes of errors, evaluating hundreds of responses until I consistently managed to get an 80-90% accuracy rate.</p>
<p>These changes were all easy to make, but I didn&rsquo;t know to make them until I systematically reviewed my inputs and outputs.Once I did, getting better performance from my LLM pipeline took very little time.</p>
<p>As a bonus, I&rsquo;ve stashed away every evaluated response so that when it makes sense for me to fine-tune a model for my task, I already have the dataset to do it.</p>
<p><a href="https://www.saxifrage.xyz/post/ai-wrapper">This post shares that ~2K data points is when a fine-tuned <code>gpt-3.5-turbo</code> begins performing on par with an optimized prompt.</a></p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/Screenshot-2024-02-03-at-4.37.00-PM.png"
        alt="The &amp;ldquo;two guys on a bus&amp;rdquo; meme. The sad side says &amp;ldquo;You while evaluating your LLM results.&amp;rdquo; The happy side says &amp;ldquo;You after evaluating your LLM results&amp;rdquo;"
        title="I&rsquo;m not going to lie: it is boring to evaluating hundreds of LLM responses. But you will have better outcomes and become a better practitioner when you do."
    />
    <figcaption class="text-center">I&rsquo;m not going to lie: it is boring to evaluating hundreds of LLM responses. But you will have better outcomes and become a better practitioner when you do.</figcaption>
</figure>

</p>
<p>The last thing that evaluating my LLM results systematically has helped me with: I&rsquo;ve built useful intuition around what a good prompt needs to have and what improvements are worth making now vs. later.</p>
<p>Today, my initial attempts at prompting LLMs work more effectively and my improvements are swift and significant. I know that a good, diverse set of few-shot examples is the highest leverage way to improve a prompt so I race to get good examples, fast.</p>
<p>My guesses around how I should I should chunk lengthy passages are more accurate, and I have a sense for how big those chunks should be (4-6K tokens is an anecdotal sweet spot, but up to 10K can work well in many cases).</p>
<p>I&rsquo;ve picked up tricks and techniques that I use constantly, like <a href="https://x.com/voberoi/status/1739756792731619548?s=20">this one about making up your own markup language to prevent hallucinations</a>, and using chain-of-thought not only to get better answers, but to observe why the LLM did what it did so I know how it screwed up or, occasionally, how it made a surprising and accurate association.</p>
<p>All this is to say: if the performance of your LLM results matters for your use case, you should be systematically evaluating them. And I mean you, specifically: the person implementing the feature that relies on LLM output.</p>
<p>Scaling requires you to outsource this work, and you might need to eventually.</p>
<p>But you can get very far doing this work on your own, you will learn a ton, and you will gain important context and control over the quality of your LLM-powered feature by doing it yourself.</p>
]]></content:encoded>
    </item>
    <item>
      <title>A UX-centric approach to navigating city council hearings with LLMs</title>
      <link>https://vikramoberoi.com/posts/a-ux-centric-approach-to-navigating-city-council-hearings-with-llms/</link>
      <pubDate>Fri, 19 Jan 2024 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/a-ux-centric-approach-to-navigating-city-council-hearings-with-llms/</guid>
      <description>A prototype that makes it easier to navigate and research city council hearings using LLMs (links below). 👉&#xA;This blog post discusses a prototype I worked on which is now live at https://citymeetings.nyc.&#xA;citymeetings.nyc is a tool built to help folks navigate NYC city meetings easily.&#xA;I&amp;rsquo;ve been using LLMs to glean information from NYC city council meetings as part of my work writing this newsletter on NYC city council activity.</description>
      <content:encoded><![CDATA[<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/Xnapper-2024-01-19-17.04.46-1.png"
        alt="A screenshot of a prototype of a tool that makes it easier to navigate city council hearings."
        title="A prototype that makes it easier to navigate and research city council hearings using LLMs (links below)."
    />
    <figcaption class="text-center">A prototype that makes it easier to navigate and research city council hearings using LLMs (links below).</figcaption>
</figure>

</p>
<div class="bg-featured callout p-6 flex items-start gap-5 my-8">
    
    <p class="text-xl">👉</p>
    
    <div class="my-0"><p>This blog post discusses a prototype I worked on which is now live at <a href="https://citymeetings.nyc">https://citymeetings.nyc</a>.</p>
<p>citymeetings.nyc is a tool built to help folks navigate NYC city meetings easily.</p>
</div>
</div>

<p>I&rsquo;ve been using LLMs to glean information from NYC city council meetings as part of my work writing <a href="https://buttondown.email/voberoi">this newsletter on NYC city council activity</a>.</p>
<p>For a lot of this work I write prompts and pipe text from bills, memos, and transcripts into Simon Willison&rsquo;s <a href="https://llm.datasette.io/en/stable/"><code>llm</code> tool</a>. It is incredibly handy.</p>
<p>Where this approach fails is when I&rsquo;m using LLMs to navigate long council meetings (and, unfortunately, most council meetings are long).</p>
<p>My goal is to find things that are &ldquo;notable&rdquo; in these meetings so I can write about them. Notability is hard to define, but here are some things that might fit my criteria:</p>
<ul>
<li>New facts that emerge from hearings.</li>
<li>Contentious exchanges between council members and a city agency.</li>
<li>Surprising testimonies from entities in NYC.</li>
<li>Passionate statements by council members about hot-button topics.</li>
<li>Things the public does not know about, but might find interesting.</li>
</ul>
<p>Transcripts for these meetings are regularly 40k to 100K tokens, which <code>gpt-4-turbo</code>&rsquo;s context window fits comfortably. Unfortunately, I run into two problems:</p>
<ol>
<li><strong>My output is way less useful with long contexts</strong>, which is likely due to the <a href="https://arxiv.org/abs/2307.03172">lost-in-the-middle problem</a>: LLMs don&rsquo;t effectively use long contexts and disproportionately weight context at the beginning and end of inputs.</li>
<li><strong>Prompts are more effective when they have the right context and clear instructions</strong>, but &ldquo;notability&rdquo; is hard to define and requires context around the NYC council&rsquo;s workings and NYC current events.</li>
</ol>
<p>I find myself having many conversations using the <a href="https://llm.datasette.io/en/stable/"><code>llm</code> CLI</a>, and then reading the raw transcript/watching the video to seek neighboring context and verify answers I get. It&rsquo;s not that effective: I still have to read a lot and I have to get lucky in my quest for notable bits of info.</p>
<p>What I&rsquo;ve <strong><em>really</em></strong> wanted for these meetings is an index that lets me browse a transcript quickly and jump to potentially high-signal segments. I want to skip procedural content, like roll calls, and jump straight to an exchange that starts with a council member&rsquo;s question. Or I want to browse all the public testimonies at the end and see who showed up to the hearing and why.</p>
<p>
<img
    class="mx-auto drop-shadow"
    src="images/image.png"
    alt=""
/>

<del>Here is a prototype that attempts this with two council meetings:</del></p>
<ul>
<li><del>A December 7th, 2023 hearing on the rat problem in NYC.</del></li>
<li><del>A December 20th, 2023 city council stated meeting</del></li>
</ul>
<p>Visit <a href="https://citymeetings.nyc">citymeetings.nyc</a> to use the tool.</p>
<p>For these, I use GPT-4 to generate chapters and I worked with my parter at <a href="https://baxterhq.com">Baxter</a> to glue it together in the UI you see above.</p>
<p>Using this tool, I can browse chapters and click on one to seek to that point in both the video and transcript. After clicking on a chapter, I can seek in a finer-grained way by clicking on a timestamp in the transcript.</p>
<p>That second feature is not AI, but it&rsquo;s important because it leaves a margin for error when LLMs inevitably mess up chapter boundaries. The chapters in the prototype need a lot of improvement, but this UX is still orders of magnitude better for research than:</p>
<ul>
<li>&hellip; piping 50K tokens into <a href="https://llm.datasette.io/en/stable/"><code>llm</code></a> and praying for something useful.</li>
<li>&hellip; reading a city council transcript.</li>
<li>&hellip; watching a city council meeting.</li>
</ul>
<p>I&rsquo;m wildly excited about research tools using LLMs because things like this are possible now that were not possible a year ago (GPT-4 only came out 10 months ago).</p>
<p>I&rsquo;m also excited about how these tools can be applied to repurpose virtually all audio and video content out there for personal use</p>
<p><a href="https://tomtunguz.com/">Tomasz Tunguz</a> linked to a video <a href="https://tomtunguz.com/the-missing-bschool-class/">in today&rsquo;s newsletter</a> that I was about to watch, but I immediately bounced when I saw it was 3 hours long. I will never watch <a href="https://www.youtube.com/watch?v=MtrkDoQFArU">the video he linked to</a>, but I would have spent 10 minutes browsing it using the tool I built and probably gotten what Tomasz got out of it.</p>
<p>I&rsquo;m continuing work on this tool in service of <a href="https://buttondown.email/voberoi">my newsletter on city council activity</a> and because it is gobs of fun.</p>
<p>I&rsquo;ll be releasing it for the public when it&rsquo;s ready. <strong>(Update: It is available now at</strong> <a href="https://citymeetings.nyc"><strong>citymeetings.nyc</strong></a><strong>)</strong></p>
<p>I&rsquo;ll also be writing about specific challenges involved in things like speaker identification, improving chapter quality, extracting pull quotes, search (semantic and otherwise), and the realities of operating software like this. <strong>(Update: I gave a talk on this topic at <a href="https://nycsodata24.sched.com/event/1aiLm/how-i-use-ai-to-make-it-easy-to-navigate-city-council-meetings">NYC School of Data</a> and published my annotated slides <a href="https://vikramoberoi.com/posts/how-citymeetings-nyc-uses-ai-to-make-it-easy-to-navigate-city-council-meetings/">here</a>.)</strong></p>
]]></content:encoded>
    </item>
    <item>
      <title>What&#39;s in the NYC city council&#39;s public records?</title>
      <link>https://vikramoberoi.com/posts/whats-in-the-nyc-city-councils-public-records/</link>
      <pubDate>Fri, 05 Jan 2024 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/whats-in-the-nyc-city-councils-public-records/</guid>
      <description>👉&#xA;This is a post I wrote for my newsletter, Keys to the City Council, in which I cover NYC City Council legislative activity. In it, I talk about:&#xA;How legislation snakes its way through city council. All the records the council generates along the way. What you can and can&amp;rsquo;t find in them. How I use AI to sift through it with techniques you can use. Every bill that makes it to a vote in the NYC city council passes.</description>
      <content:encoded><![CDATA[<div class="bg-featured callout p-6 flex items-start gap-5 my-8">
    
    <p class="text-xl">👉</p>
    
    <div class="my-0"><p>This is a post I wrote for my newsletter, <a href="https://buttondown.email/voberoi">Keys to the City Council</a>, in which I cover NYC City Council legislative activity. In it, I talk about:</p>
<ul>
<li>How legislation snakes its way through city council.</li>
<li>All the records the council generates along the way.</li>
<li>What you can and can&rsquo;t find in them.</li>
<li>How I use AI to sift through it with techniques you can use.</li>
</ul></div>
</div>

<p>Every bill that makes it to a vote in the NYC city council passes.</p>
<p>(<a href="https://www.cityandstateny.com/politics/2022/05/new-york-city-council-failed-pass-bill-what-happened/367487/">One bill didn&rsquo;t pass in 2022</a>. Before that, the last time a bill didn&rsquo;t pass was in the 90s.)</p>
<p>The council holds Stated Meetings twice a month to introduce new legislation and vote on bills, and the outcome of these meetings is determined by the time the agenda is up days in advance on <a href="https://legistar.council.nyc.gov/Calendar.aspx">Legistar</a>, the council&rsquo;s public records system.</p>
<p>Stated Meetings are 90% procedural and 10% performative. Council members use whatever time they get on the floor to tout legislative achievements and explain &ldquo;nay&rdquo; votes they cast.</p>
<p>The meetings are predictable, &ndash; everything passes! &ndash; but the number of bills passed is surprising: the council passes dozens of bills every two weeks, passing over fifty at the last meeting.</p>
<p>Bills take anywhere from months to years to go from introduction to vote. <strong>Timelines exceeding one year are not unusual.</strong></p>
<p>
<img
    class="mx-auto drop-shadow"
    src="https://assets.buttondown.email/images/0a2cc446-6cc1-4730-b45a-5ba84a47c83d.png?w=960&amp;fit=max"
    alt="A screenshot of the bill from a schoolhouse rock song on the steps of the capitol."
/>

</p>
<p><a href="https://legistar.council.nyc.gov/LegislationDetail.aspx?ID=5755069&amp;GUID=B2A8438C-77D2-4AE1-9DB4-66AC11D37874&amp;Options=ID%7CText%7COther%7C&amp;Search=Int.+638">Introduction 638</a> from the NYPD transparency package passed two weeks ago.</p>
<p>It took <strong>1 year and 4 months</strong> to come to a vote.</p>
<p>At some point in 2022, <a href="https://council.nyc.gov/district-16/">Councilmember Althea Stevens</a> decided to prioritize NYPD transparency and her office worked with the council&rsquo;s Legislation Division to write the first draft of the law.</p>
<p>(The Legislation Division&rsquo;s <a href="https://council.nyc.gov/legislation/wp-content/uploads/sites/55/2023/03/NYC-Bill-Drafting-Manual-2022-FINAL.pdf">Bill Drafting Manual</a> is an interesting read!)</p>
<p>Then Stevens <strong>formally introduced</strong> the bill at a Stated Meeting in August 2022, where it was assigned to the Committee on Public Safety.</p>
<p>(Whenever a bill is introduced, it gets assigned to one of <a href="https://council.nyc.gov/committees/">36 committees</a> responsible for deliberating and holding hearings on bills pertinent to them.)</p>
<p>A bill&rsquo;s public record starts the moment it is introduced. Here is 638&rsquo;s timeline over 16 months:</p>
<ul>
<li><strong>August 2022</strong>: the bill was introduced.</li>
<li><strong>March 2023</strong>: a multi-hour hearing was held and the bill was on the agenda.</li>
<li><strong>December 2023:</strong> the bill was revised, brought to the floor, and passed.</li>
</ul>
<p>There are 18 documents in <a href="https://legistar.council.nyc.gov/LegislationDetail.aspx?ID=5755069&amp;GUID=B2A8438C-77D2-4AE1-9DB4-66AC11D37874&amp;Options=ID%7CText%7COther%7C&amp;Search=Int.+638">the public record for Introduction 638</a> clustered around these three moments. They include every version of the bill, hearing transcripts, written testimonies, and reports with additional context and data.</p>
<p>Here&rsquo;s what all this looks like on Legistar:</p>
<p>
<img
    class="mx-auto drop-shadow"
    src="https://assets.buttondown.email/images/2177478c-0edb-4f17-b863-b81250508f5f.png?w=960&amp;fit=max"
    alt="A screenshot from NYC&amp;rsquo;s legistar showing Introduction 638&amp;rsquo;s public records"
/>

With this data you can:</p>
<ul>
<li>Find out what a bill does by reading its text and summaries.</li>
<li>Gather context and motivations behind a bill in committee reports.</li>
<li>Learn what agencies and individuals who testified at hearings think about it.</li>
<li>Find out how much a piece of legislation will cost the city by reading fiscal reports.</li>
</ul>
<p>The vast majority of bills never make it to the floor.</p>
<p>Out of <strong>1,281 bills</strong> that were introduced in the last two years, <strong>only ~20% made it to a vote.</strong> Close to 1,000 bills were drafted and then hit a snag somewhere.</p>
<p><a href="https://legistar.council.nyc.gov/LegislationDetail.aspx?ID=5656503&amp;GUID=EEF0CBCF-BE73-4CB2-895D-EB252FFB8E09&amp;Options=Advanced&amp;Search=">Introduction 396</a> is one such bill. It requires preschools to maintain lead levels in drinking water below some amount determined by the Department of Health (DOH), with a yearly inspection.</p>
<p><a href="https://council.nyc.gov/district-30/">Councilmember Robert Holden</a>, the bill&rsquo;s sponsor, introduced 396 at a Stated Meeting in May 2022. It got assigned to the Committee on Health and then nothing happened: there were no hearings, reports, or votes.</p>
<p>396 seems important, but information beyond the bill&rsquo;s text isn&rsquo;t in the public records. I can&rsquo;t tell you how much of a risk lead poisoning is in our preschools, but a council member&rsquo;s office thought it might be high enough to address it with legislation.</p>
<p>Even when bills pass, the records don&rsquo;t tell us the story behind them. The public information on <a href="https://legistar.council.nyc.gov/Calendar.aspx">Legistar</a> is sparse and lacks broader context.</p>
<p>
<img
    class="mx-auto drop-shadow"
    src="https://assets.buttondown.email/images/febb9b03-264a-429b-a7e1-c99d040c04db.png?w=960&amp;fit=max"
    alt="A photo of lots of file folders stacked and disorganized."
/>

But there&rsquo;s still a lot to discover. The council, like every legislative body in the US, <strong>puts out reams of documents that barely anyone reads.</strong></p>
<p>Surprising details are buried in reports. Here are two from issues <a href="https://buttondown.email/voberoi/archive/jay-z-bike-lanes-housing-and-rats/">one</a> and <a href="https://buttondown.email/voberoi/archive/coastal-waters-nypd-nycs-overdue-payments/">three</a> of this newsletter:</p>
<ul>
<li>From <a href="https://legistar.council.nyc.gov/LegislationDetail.aspx?ID=6203172&amp;GUID=42410336-8532-45D3-B677-9BBA0F1D2D6C">a committee report found here</a>: the city pays service providers so late that they keep themselves afloat with loans, paying <strong>an average of $223K annually</strong> in interest.</li>
<li>From <a href="https://legistar.council.nyc.gov/LegislationDetail.aspx?ID=5871100&amp;GUID=0E5050A7-A87F-49DB-9838-C34497552E75">a fiscal impact report found here</a>: noise cameras used to detect noise violations cost $35,000 each.</li>
</ul>
<p>Bills might get revised and change materially from when they are first introduced. This happened with the bike lane bill covered in this <a href="https://buttondown.email/voberoi/archive/jay-z-bike-lanes-housing-and-rats/">first issue of Keys to the City Council</a>:</p>
<ul>
<li>The first version enables bike lane improvements fewer than 4 blocks in length without a notice period or community board hearing, <strong>on par with other transportation infrastructure.</strong></li>
<li>The final version of the bill subjects bike lanes of any length to this red tape, <strong>unlike other transportation infrastructure.</strong></li>
</ul>
<p>Exchanges between council members and city agencies reveal new information. For <a href="https://buttondown.email/voberoi/archive/the-council-fights-massive-budget-cuts/">the second issue of Keys to the City Council</a> I poked around a hearing on small business contracts. In it, agencies share how one caterer takes advantage of NYC&rsquo;s bureaucracy by <strong>charging rates ranging from $3/meal to $15/meal to different agencies for the same meals.</strong></p>
<p>As I read documents and build tools and techniques to navigate them &ndash; how I spend most of my time on Keys to the City Council &ndash; I&rsquo;m able to find notable details more quickly.</p>
<p>
<img
    class="mx-auto drop-shadow"
    src="https://assets.buttondown.email/images/028ecfe4-0a3c-42de-9328-38efda809040.png?w=960&amp;fit=max"
    alt="A katiebcartoons cartoon: &amp;ldquo;In this jab at those people in vests who stop you on the street corner asking if you have a minute for human rights, we see one of those workers stopping a passerby, who happens to be a robot. Robots, of course, have no time for human rights.&amp;rdquo;"
/>

I want to share some of the techniques I use to help me navigate legislative documents and hearings with AI. You can try these out at home using ChatGPT.</p>
<p>I pay for ChatGPT ($20/mo) so I get access to GPT-4, a more powerful language model that I use exclusively because GPT-3.5&rsquo;s performance on these tasks is much worse.</p>
<p>The prompts below use bills found on <a href="https://legistar.council.nyc.gov/Calendar.aspx">Legistar</a> and <a href="https://codelibrary.amlegal.com/codes/newyorkcity/latest/overview">sections of the NYC legal code found on American Legal Publishing</a>.</p>
<p>Here&rsquo;s the prompt I use to explain what a bill does, with a section to provide context from the legal code if necessary:</p>
<blockquote>
<p>Please summarize this NYC bill and explain its implications in a way that a high schooler would understand.</p>
<p>The bill may refer to sections in the NYC legal code. I have provided the bill and relevant sections of the legal code below.</p>
<p>&mdash; THE BILL &mdash;</p>
<p><code>Copy and paste the bill text</code></p>
<p>&mdash; RELEVANT SECTIONS FROM THE NYC LEGAL CODE &mdash;</p>
<p><code>Copy and paste relevant sections of the legal code</code></p>
</blockquote>
<p>Asking ChatGPT to explain implications &ldquo;in a way that a high schooler would understand&rdquo; is a proxy for &ldquo;keep it simple&rdquo; that encodes instructions like &ldquo;don&rsquo;t go clause by clause and explain it to me like a legal scholar&rdquo; and &ldquo;keep the summary high-level and use basic prose&rdquo;. It works well.</p>
<p>I&rsquo;ll then have follow-up questions and have a full conversation about the bill. I use questions to verify my understanding, dig into details, and ask ChatGPT to spit out clauses, verbatim, that achieve something in particular.</p>
<p>It&rsquo;s important to verify ChatGPT&rsquo;s answer because ChatGPT <a href="https://en.wikipedia.org/wiki/Hallucination_(artificial_intelligence)">hallucinates</a>.</p>
<p>Collectively, all this takes me 2-10 minutes where reading and parsing a bill alone might take me 30 minutes or more.</p>
<p>To understand how versions of a bill differ, here is how I prompt ChatGPT:</p>
<blockquote>
<p>I will give you two versions of a NYC bill that passed, Version A and Version B. Please tell me what changed from Version A to Version B and what the implications are in a way a high schooler would understand?</p>
<p>&mdash; VERSION A &mdash;</p>
<p><code>Copy and paste the text from Version A</code></p>
<p>&mdash; VERSION B &mdash;</p>
<p><code>Copy and paste the text from Version B</code></p>
</blockquote>
<p>I used this prompt + followup question to find subtle but material changes in the bike bill covered in the <a href="https://buttondown.email/voberoi/archive/jay-z-bike-lanes-housing-and-rats/">first issue of Keys to the City Council</a></p>
<p>I also use it to skip revisions that aren&rsquo;t notable and save time. The screenshot below compares both versions of <a href="https://legistar.council.nyc.gov/LegislationDetail.aspx?ID=5755069&amp;GUID=B2A8438C-77D2-4AE1-9DB4-66AC11D37874">Introduction 638</a>: the changes are immaterial.</p>
<p>
<img
    class="mx-auto drop-shadow"
    src="https://assets.buttondown.email/images/a9f2447f-374d-494a-82df-dd773644717b.png?w=960&amp;fit=max"
    alt="image.png"
/>

Outside using prompts to analyze bills, I&rsquo;ve been building custom tools to find interesting information in hearings. This turns out to be a much more challenging task.</p>
<p>Part of this is because it&rsquo;s hard to articulate what &ldquo;interesting information&rdquo; even <strong>looks like</strong> in a hearing. To prompt language models so you get output that you want, you need to be wildly pedantic or use proxies that express your intent well enough, like &ldquo;explain this to me like I&rsquo;m a high schooler.&rdquo;</p>
<p>Saying &ldquo;tell me surprising facts people said in this hearing&rdquo; doesn&rsquo;t work.</p>
<p>The other reason this is hard is because hearings are long, on the order of 30K to 100K messily-transcribed words. Language models struggle to get useful information out of large bodies of text <strong>even when your prompt works well on smaller chunks of text.</strong></p>
<p>So I&rsquo;m hacking on research tools to help me navigate hearings more quickly instead of relying on GPT-4 to give me answers.</p>
<p>In particular, I&rsquo;d like a page where I can see a video of a hearing on the top and on the bottom:</p>
<ul>
<li>Neatly segmented chapters of the hearing: &ldquo;Roll Call&rdquo;, &ldquo;Brooks-Powers expresses concern about NYC&rsquo;s rat problem in her opening statement&rdquo;, &ldquo;An exchange about wheelie bins with DSNY&rdquo;, etc.</li>
<li>Each chapter has an associated summary that I can skim, with relevant pull quotes.</li>
<li>I can expand the transcript in each chapter if I need to skim it.</li>
<li>I can click on any of these things to automatically seek to a point in a 1-to-4-hour video: a chapter, a pull quote, a single word in the transcript.</li>
</ul>
<p>Doing this requires creating smaller transcript chunks for GPT-4 to digest, multiple steps involving different prompts, and a lot of manual evaluation to test and improve the quality of chapters, summaries, and pull quotes.</p>
<p>I share more about this work on <a href="https://twitter.com/voberoi">Twitter</a> and <a href="https://www.threads.net/@vikramo">Threads</a> if you&rsquo;d like to follow along.</p>
<p>I&rsquo;m building this tool for Keys to the City Council. I think it could be useful to reporters and government affairs folks. If you or someone you know would like to chat about it, please send me a message!</p>
<p>
<img
    class="mx-auto drop-shadow"
    src="https://assets.buttondown.email/images/5f8121ef-c6a4-4492-8390-ce1190cc48db.png?w=960&amp;fit=max"
    alt="A cartoon by katiebcartoons: In this pun on the new year&amp;rsquo;s resolution to make new friends, we see someone literally making a new friend - i.e. a made scientist making a Frankenstein&amp;rsquo;s monster. She says, &amp;ldquo;It&amp;rsquo;s alive! And just in time to join my book club!&amp;rdquo;"
/>

Going forward, I&rsquo;m going to focus Keys to the City Council on bills and records that are interesting but not widely covered: new legislation, important nuances in bills and revisions, and context buried in hearings and reports.</p>
<p>In some cases I&rsquo;ll add to mainstream stories &ndash; for example, sharing multiple major bills in the NYPD transparency package that were overshadowed by the one that Adams is likely to veto. Those bills, alone, should generate significant, new public information about NYPD&rsquo;s operations in the next 12-24 months.</p>
<p>I&rsquo;m also going to match the council&rsquo;s cadence and write the newsletter <strong>every two weeks</strong> when the council votes. I&rsquo;ll play around with same format I used in <a href="https://buttondown.email/voberoi/archive/jay-z-bike-lanes-housing-and-rats/">issue 1</a> and <a href="https://buttondown.email/voberoi/archive/coastal-waters-nypd-nycs-overdue-payments/">issue 3</a>, sharing new legislation along with details.</p>
<p>I won&rsquo;t cover major stories like the budget cuts in <a href="https://buttondown.email/voberoi/archive/the-council-fights-massive-budget-cuts/">issue 2</a>. The cuts were covered everywhere, and stories like it require context absent from public records that I don&rsquo;t have, that journalists gather, think, and write about all day.</p>
<p>It was fun to write that issue, but it&rsquo;s not in my wheelhouse and it took effort and time away from what I want do: poke my (AI-enabled) nose into public records and share what I find.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How many concurrent conversations can you sustain before you hit OpenAI&#39;s rate limits?</title>
      <link>https://vikramoberoi.com/posts/how-many-concurrent-conversations-can-you-sustain-before-you-hit-openais-rate-limits/</link>
      <pubDate>Fri, 11 Aug 2023 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/how-many-concurrent-conversations-can-you-sustain-before-you-hit-openais-rate-limits/</guid>
      <description>This bot can unfortunately sustain at most one concurrent conversation. Credit: katiebcartoons.com Over at Baxter we&amp;rsquo;ve been working with a client to build an LLM-powered chat bot that we&amp;rsquo;re deploying into Fortune 100 enterprises.&#xA;Employees at these enormous companies will chat with this bot and, uh, unfortunately I cannot share much more than that.&#xA;I recently had to ballpark how many concurrent chats we could support before we&amp;rsquo;d hit our OpenAI rate limit.</description>
      <content:encoded><![CDATA[<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/turing_test.jpg"
        alt="A cartoon featuring a woman admiring a robot with the caption &amp;ldquo;Sure, he didn&amp;rsquo;t pass the Turing test. But what was so great about being human anyway?&amp;rdquo;"
        title="This bot can unfortunately sustain at most one concurrent conversation. Credit: katiebcartoons.com"
    />
    <figcaption class="text-center">This bot can unfortunately sustain at most one concurrent conversation. Credit: <a href="https://katiebcartoons.com">katiebcartoons.com</a></figcaption>
</figure>

</p>
<p>Over at <a href="https://baxterhq.com">Baxter</a> we&rsquo;ve been working with a client to build an LLM-powered chat bot that we&rsquo;re deploying into Fortune 100 enterprises.</p>
<p>Employees at these enormous companies will chat with this bot and, uh, unfortunately I cannot share much more than that.</p>
<p>I recently had to ballpark how many concurrent chats we could support before we&rsquo;d hit our OpenAI rate limit. I came up with a basic model and am sharing it here in case it comes in handy for others.</p>
<h2 id="heres-the-kind-of-bot-for-which-this-ballpark-estimate-makes-sense">Here&rsquo;s the kind of bot for which this ballpark estimate makes sense:</h2>
<ul>
<li>It uses OpenAI&rsquo;s ChatCompletion APIs.</li>
<li>The user talks, the bot talks, the user talks, the bot talks, and so on&hellip;</li>
<li>Every time a user sends a chat, that chat is sent to a ChatCompletion endpoint along with the entire message stack: all <code>system</code>, <code>assistant</code>, and <code>user</code> messages up to and including the user&rsquo;s message.</li>
</ul>
<p>This seems like it holds for gobs of products and features I see in the market.</p>
<h2 id="here-are-assumptions-the-model-makes">Here are assumptions the model makes:</h2>
<ul>
<li>The concurrent chats are all fresh, new conversations as opposed to a long conversation being re-entered.</li>
<li>They all start at the same time.</li>
<li>We&rsquo;re looking for the number of these conversations that can run concurrently&hellip;</li>
<li>&hellip; without any of them running into one of OpenAI&rsquo;s rate limits.</li>
</ul>
<h2 id="deriving-a-ballpark-estimate">Deriving a ballpark estimate</h2>
<p>OpenAI&rsquo;s rate limits are comprised of two values:</p>
<ol>
<li><strong>TPM:</strong> tokens per minute.</li>
<li><strong>RPM:</strong> requests per minute.</li>
</ol>
<p>GPT-4’s base values for these are 40,000 TPM, 200 RPM. You are rate limited the moment you hit one of these.</p>
<p>This means that if 200 people log in and send a message, your service falls over immediately. Everyone will send one message, and then their next message will fail.</p>
<p>What we want is: what is the maximum # of users can sustain a conversation without getting rate limited?</p>
<p>That number, whatever it is, is far less than 200.</p>
<p>To get a rough estimate, you want to grab these numbers for your bot:</p>
<ul>
<li>The average <strong>Chat RPM:</strong> The average # of requests a regular <strong>chat session</strong> makes per minute.</li>
<li>The average <strong>Chat TPM:</strong> The average # of tokens a regular <strong>chat session</strong> sends per minute.</li>
</ul>
<p>I recommend computing these from real chat sessions your users have done:</p>
<ul>
<li><strong>Chat RPM</strong> maps to the number of <code>user</code> role messages sent per minute in a session.</li>
<li><strong>Chat TPM</strong> takes more work. Whenever a user sends a message, you have to sum up all the tokens of all messages up to and including the user&rsquo;s message. Add up each of these token counts, divide them by the # of minutes the chat lasted.</li>
<li>Use <a href="https://github.com/openai/tiktoken">tiktoken</a> to count tokens.</li>
</ul>
<p>Between OpenAI&rsquo;s response times, a user pausing/thinking, and then sending a message, you&rsquo;re likely to get somewhere between 1-3 requests per minute per chat.</p>
<p>Meanwhile, because the number of tokens you send to OpenAI every time a user chats <em>grows cumulatively</em>, <strong>Chat TPM</strong> is your likely bottleneck and it might even surprise you how big it gets.</p>
<p>I recommend capping the length of these conversations at what you believe is a reasonable duration. You want to remove outliers – for example, if a user had a conversation on day 1 and then came back on day 2 to send another message.</p>
<p>(Unless that&rsquo;s the norm for your app, in which case you might not want to use this model.)</p>
<p>Once you have these numbers, the # of users who can sustain a conversation concurrently is the lesser of:</p>
<ul>
<li><strong>OpenAI RPM Rate Limit /</strong> the average <strong>Chat RPM</strong></li>
<li><strong>OpenAI TPM Rate Limit /</strong> the average <strong>Chat TPM</strong></li>
</ul>
<p>That&rsquo;s it!</p>
<p>The longer your user&rsquo;s conversations last, the fewer of them you&rsquo;ll be able to sustain concurrently. Again, this is because of the cumulative growth in the tokens you send over the course of a conversation: whenever you send a ChatCompletion request, you&rsquo;re sending over the entire conversation every time.</p>
]]></content:encoded>
    </item>
    <item>
      <title>An internship working on &#34;Customers who bought this also bought&#34; at Amazon 16 years ago</title>
      <link>https://vikramoberoi.com/posts/an-internship-working-on-customers-who-bought-this-also-bought-at-amazon-16-years-ago/</link>
      <pubDate>Mon, 29 May 2023 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/an-internship-working-on-customers-who-bought-this-also-bought-at-amazon-16-years-ago/</guid>
      <description>I wrote this tweet about my Amazon internship in 2007 as I rolled out of bed yesterday morning and it went viral.&#xA;(Yes, you are correct: there is only one Harry Potter 7 book. And no, I did not recommend the second one into print, but I do love that joke.) How fun!&#xA;That internship was my first work experience overall in software and I got incredibly lucky being placed on the Similarities team.</description>
      <content:encoded><![CDATA[<p>I wrote <a href="https://twitter.com/voberoi/status/1662790190761377792">this tweet</a> about my Amazon internship in 2007 as I rolled out of bed yesterday morning and it went viral.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/Screen-Shot-2023-05-29-at-10.49.47-AM.png"
        alt="A screenshot of a tweet by the author stating &amp;ldquo;I interned at Amazon in 2007 and his entire task was to test changes to the algorithm so that the first Harry Potter 7 book (released that summer) wouldn&amp;rsquo;t appear in &amp;lsquo;Customers who bought this also bought&amp;rsquo; for literally everything (e.g. mops, Now That&amp;rsquo;s What I Call Music 22, etc.)&amp;rdquo;"
        title="(Yes, you are correct: there is only one Harry Potter 7 book. And no, I did not recommend the second one into print, but I do love that joke.)"
    />
    <figcaption class="text-center">(Yes, you are correct: there is only one Harry Potter 7 book. And no, I did not recommend the second one into print, but I do love that joke.)</figcaption>
</figure>

</p>
<p>How fun!</p>
<p>That internship was my first work experience overall in software and I got incredibly lucky being placed on the Similarities team.</p>
<p>One could do way worse as a 20-year old landing an internship at Amazon: the Similarities team was doing cutting edge recommendations and experimentation work, I got to deploy and see feedback from my work on-site every week, and I was paired with a kind and helpful mentor on a great team.</p>
<p>My experience that summer is the reason I continued doing data &amp; systems work in college and after I graduated.</p>
<p>The Similarities team was responsible for producing the dataset that powered &ldquo;Customers who bought this also bought&rdquo;. The dataset was also used in personalized recommendations they&rsquo;d surface to customers elsewhere: on-site and in emails.</p>
<p>~20% of revenue was attributed to similarities at the time if I remember correctly (<a href="https://twitter.com/voberoi/status/1662846509543505920?s=20">my mentor was kind enough to terrify me with an estimate of the revenue Amazon lost from an an outage I initiated</a>). So they were an effective and important part of the site. But there were still a number of unintuitive similarities that would appear in the &ldquo;Customers who bought this also bought&rdquo; widget.</p>
<p>While there was a long tail of random issues in different product categories, – Amazon had begun a rapid expansion beyond books and was working on fixing them – the biggest problem, by far, was Harry Potter.</p>
<p>More specifically, in the summer of 2007 it was Harry Potter and the Deathly Hallows.</p>
<p>It would show up as a similarity <strong><em>everywhere</em></strong>. Like, you&rsquo;d be on Amazon buying a mop and the similarities widget would show a recommendation for Harry Potter and the Deathly Hallows, followed by Pine-Sol and a bucket.</p>
<p>The team computed item-to-item similarities using collaborative filtering with customer order baskets as the input. Put simply, if customers bought products A and B together frequently enough, then Amazon would present A and B as similar items.</p>
<p>But what happens when the same product appears in virtually every order basket? <a href="https://glinden.blogspot.com/2006/03/early-amazon-similarities.html">The Harry Potter problem</a>.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/order-of-the-phoenix-1.png"
        alt="In this pun on the fifth HP book, Harry Potter and the Order of the Phoenix, we see Harry Potter working at a fast food restaurant taking the complicated order of a customer who is also a phoenix."
        title="Credit: katiebcartoons.com"
    />
    <figcaption class="text-center">Credit: <a href="https://katiebcartoons.com">katiebcartoons.com</a></figcaption>
</figure>

</p>
<p>The similarities team deployed me to one idea they had to address the Harry Potter problem: could they use feedback from users to cull unintuitive similarities?</p>
<p>Amazon got feedback in two ways:</p>
<ul>
<li><strong>Implicitly:</strong> clickstream and conversion data from the similarities widget (&ldquo;Customers who bought this also bought&rdquo;)</li>
<li><strong>Explicitly:</strong> users could provide feedback on personalized recommendations they received in emails/on-site. I don&rsquo;t know if this still exists.</li>
</ul>
<p>So I spent the summer trying to use the output of Amazon&rsquo;s collaborative filtering algorithm + all this clickstream/conversion/feedback data to address unintuitive similarities. The thinking was that if a similarity was unintuitive, then presumably it&rsquo;d underperform by some measure based on user feedback.</p>
<p>My primary target was Harry Potter and the Deathly Hallows: it stuck out like a sore thumb and it was an easy way to see if an approach was working qualitatively.</p>
<p>Amazon similarities were served by a <a href="https://en.wikipedia.org/wiki/Berkeley_DB">Berkeley DB</a> (BDB) file at the time. BDBs are embedded key-value stores – a file you can ship around with a format optimized for key-value lookups. Amazon would crunch numbers and emit a new similarities BDB nightly or weekly (I don&rsquo;t remember which).</p>
<p>The similarities BDB mapped ASINs (product IDs in Amazon parlance) to lists of ASINs, like this:</p>
<p><strong><code>B00P0H6836</code>:</strong> <code>B07K8Y6CMP</code>, <code>B07K8RWVF9</code>, <code>B07K8S9ZQZ</code>, <code>B00P0H6836</code></p>
<p>That&rsquo;s the ASIN for <a href="https://www.amazon.com/SmartCat-Natural-Clumping-Litter-20-Pound/dp/B00P0H6836?pd_rd_w=gROvs&amp;content-id=amzn1.sym.724fac2e-0491-4f7a-a10d-2221f9a8bc9a&amp;pf_rd_p=724fac2e-0491-4f7a-a10d-2221f9a8bc9a&amp;pf_rd_r=VYXKSS0B0J2XYHYSZ4BS&amp;pd_rd_wg=VclKq&amp;pd_rd_r=98fea35d-1faa-4717-aa14-a318ee9cceb2&amp;pd_rd_i=B00P0H6836&amp;ref_=pd_bap_d_grid_rp_0_1_ec_i&amp;th=1">this great cat litter</a> mapped to different sizes of pee pad refills in the &ldquo;Compare with similar items&quot;section. This is also an example of an unintuitive similarity: if I use clumping litter for my cat, it is unlikely that I will use pee pads too.</p>
<p>So each week I&rsquo;d write a Perl script to crunch clickstream/conversion/feedback data and then remove or reorder some of the mappings in the BDB file based on my algorithm that week. We&rsquo;d now have two sets of similarities:</p>
<ul>
<li><strong>A:</strong> the similarities emitted by Amazon&rsquo;s collaborative filtering algorithm</li>
<li><strong>B:</strong> <code>vikrams_algorithm_of_the_week(A)</code>, Amazon&rsquo;s similarities from A with mappings removed or reordered using whatever approach I was trying.</li>
</ul>
<p>Then I&rsquo;d send an email to the team with a link to a CGI script I wrote that allowed us to qualitatively assess B against A. It was a little web page with an input box at the top where you could enter an ASIN and it would show you similarities from A compared to similarities from B.</p>
<p>We&rsquo;d exchange emails about the quality of my similarities or talk about them in a meeting. Then my mentor would decide whether or not we would push it to production.</p>
<p>Most weeks we&rsquo;d push something to production and A/B test it. I don&rsquo;t know what percentage of the site saw my similarities, but I suspect it was low: it would take a few days for us to get conclusive results and even in 2007 Amazon got <em>tons</em> of traffic.</p>
<p>I threw a lot of things at the wall.</p>
<p>Two approaches that I remember relied on the idea that users will simply click more on items on the left side of a page. It is well-known that page position is a massive determinant of clickthrough rates. The &ldquo;Customers who Bought this Also Bought&rdquo; widget was laid out from left to right, so items in the first slot had a baked-in &ldquo;boost&rdquo;.</p>
<p>So, if that is true yet we see a similarity in the first slot &ldquo;underperform&rdquo;, maybe we should reorder it. Here are two different ways I did that:</p>
<ol>
<li><strong>A basic approach:</strong> if an ASIN in slot 1 has a lower clickthrough rate than an ASIN in slot 2, swap slots 1 and 2.</li>
<li><strong>A more complicated approach:</strong> if an ASIN in slot 1 has a statistically lower clickthrough rate compared to the ASIN in slot 2, swap slots 1 and 2.</li>
</ol>
<p>I don&rsquo;t remember the specifics for #2. It might have been something like:</p>
<ol>
<li>Get the difference in clickthrough rate between slots 1 and 2.</li>
<li>See where it falls on the distribution of &ldquo;difference in clickthrough rate between slots 1 and 2&rdquo;.</li>
<li>Decide it&rsquo;s underperforming if it is some distance away from the mean.</li>
</ol>
<p>I spent the entire summer trying stuff like this and published a log of what I did on Amazon&rsquo;s internal wiki.</p>
<p>Some takeaways I recall:</p>
<ul>
<li><strong>None of these approaches improved conversions over the baseline:</strong> I do remember thinking my similarities were better in some of our qualitative assessments, but, site-wide, customers voted with their $ and showed that they were worse.</li>
<li><strong>Simple approaches are better than complicated ones:</strong> any time I got fancy, like in the more complicated approach above, the performance of my similarities tanked.</li>
<li><strong>Invalidating a bunch of approaches and documenting them is still useful:</strong> with this work, the team had a set of hypotheses they could either ignore or approach again in the future with more clarity. It&rsquo;s a great project to deploy an intern to.</li>
<li><strong>None of these approaches come close to solving the Harry Potter problem:</strong> that book&rsquo;s Amazon detail page will be forever etched in my brain.</li>
</ul>
<p>There are many wildly qualified people who have worked on similarities and recommendations at Amazon, Netflix and elsewhere the last 15 years. Greg Linden started and led a lot of the personalization work at Amazon and has written about some of it <a href="https://glinden.blogspot.com/">on his blog</a>.</p>
<p>I don&rsquo;t know if Greg was there in 2007. My mentor and the team&rsquo;s manager during my internship keep a much lower profile, but were also extremely talented.</p>
<p>👋 to Wes &amp; Brent if you see this! Thank you for setting me up with a delightful and impactful experience 16 years ago.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Using Metaphone to handle bad transcriptions in voice search for sommeliers</title>
      <link>https://vikramoberoi.com/posts/using-metaphone-to-handle-bad-transcriptions-in-voice-search-for-sommeliers/</link>
      <pubDate>Mon, 22 May 2023 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/using-metaphone-to-handle-bad-transcriptions-in-voice-search-for-sommeliers/</guid>
      <description>If OpenAI&amp;rsquo;s Whisper was as good at transcribing wine names as this duck/skilled sommelier, this project would have been a lot simpler. Credit: katiebcartoons.com I recently prototyped a hands-free inventory counting system for sommeliers at a popular Manhattan Italian restaurant with Gabriel, my partner at Baxter.&#xA;I know the general manager (GM) at this restaurant – henceforth referred to as Ristorante – personally and he hates inventory counts. Most restaurant GMs do.</description>
      <content:encoded><![CDATA[<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/bored-duck.jpg"
        alt="A man places an order for &amp;ldquo;bored duck&amp;rsquo;s shadow&amp;rdquo;. A duck wearing a tuxedo takes the order and correctly identifies the man&amp;rsquo;s intent to order a &amp;ldquo;Bordeaux Chateau&amp;rdquo;."
        title="If OpenAI&rsquo;s Whisper was as good at transcribing wine names as this duck/skilled sommelier, this project would have been a lot simpler. Credit: katiebcartoons.com"
    />
    <figcaption class="text-center">If OpenAI&rsquo;s Whisper was as good at transcribing wine names as this duck/skilled sommelier, this project would have been a lot simpler. Credit: <a href="https://katiebcartoons.com">katiebcartoons.com</a></figcaption>
</figure>

</p>
<p>I recently prototyped a hands-free inventory counting system for sommeliers at a popular Manhattan Italian restaurant with Gabriel, my partner at <a href="https://baxterhq.com">Baxter</a>.</p>
<p>I know the general manager (GM) at this restaurant – henceforth referred to as <em>Ristorante –</em> personally and he hates inventory counts. Most restaurant GMs do. They&rsquo;re labor-intensive yet extremely important: regular inventory audits are critical for restaurants to control their margins.</p>
<p>Ristorante can clear well into 5 figures of revenue in a day. And its GM, being a skilled and responsible operator, requires his team to do an inventory audit every month. So Ristorante&rsquo;s two sommeliers arrive early in the morning on the 1st of every month to count over 1,500 different wines stored across 3 bars, a cellar, a fridge, and a storage room. It takes them over 6 hours, and the task bleeds into daily operations.</p>
<p>It&rsquo;s painful.</p>
<p>It&rsquo;s also just the kind of messy real-world problem that Gabriel and I want to try to solve for a market: is there any way we could cut the time it took these sommeliers to count inventory by half?</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/inventory-venn-1.jpg"
        alt="A venn diagram with three circles titled &amp;ldquo;Boring&amp;rdquo;, &amp;ldquo;Expensive&amp;rdquo; and &amp;ldquo;Important&amp;rdquo;. There is an arrow that points &amp;ldquo;Inventory Counting&amp;rdquo; to the intersection of those three."
        title="Inventory counts lie at this cursed intersection for restaurant GMs."
    />
    <figcaption class="text-center">Inventory counts lie at this cursed intersection for restaurant GMs.</figcaption>
</figure>

</p>
<p>I popped into Ristorante on April 1st, a busy Saturday, to observe an inventory count.</p>
<p>Ristorante&rsquo;s wine storage is organized but cramped. At the bars, many wine bottles sit on high shelves and sommeliers might be standing precariously on a stool or counter. Their hands are often not available – they&rsquo;re moving boxes, bottles, or holding a bottle up so they can identify what it is.</p>
<p>Counting wine looks something like this:</p>
<ol>
<li>Identify the box/bin or a bottle of wine you want to count. Count it.</li>
<li>With a laptop or phone next to you, look up the wine you&rsquo;re looking for in <a href="https://craftable.com/bevager/">Bevager</a>, Ristorante&rsquo;s inventory management system.</li>
<li>Enter the count and save it.</li>
</ol>
<p>Each wine takes anywhere from 15-60 seconds, <strong><em>of which 80% of the time goes to step 2: looking up the wine in Bevager.</em></strong> This is due to a combination of the following:</p>
<ul>
<li><strong>Context switching:</strong> from using one&rsquo;s arms and being perched somewhere, to using a laptop or phone.</li>
<li><strong>Wine names are entered into Bevager inconsistently:</strong> in some cases they use the wine name, in others they use the producer. These names might be on the front or back of the wine.</li>
<li><strong>Words in wine names are frequently repeated:</strong> &ldquo;chateau&rdquo;, for example.</li>
<li><strong>Vintages:</strong> you might need to pick the right vintage from 5+ entries.</li>
<li><strong>Bevager bugginess/search quality:</strong> Bevager is a fine system, but its inventory audit feature could use some love.</li>
</ul>
<p>These are all solvable problems that take up a significant percentage of the time it takes to inventory one wine bottle.</p>
<p>So we decided to start by helping a sommelier retrieve a wine in Ristorante&rsquo;s inventory system quickly and accurately without having to use her hands, with her voice.</p>
<h2 id="prototyping-voice-search-for-wines-in-3-days">Prototyping voice search for wines in 3 days</h2>
<p>A critical component of our prototype was a voice search feature that allowed a sommelier to say the name of a wine and retrieve it correctly from over 1,500 SKUs in her inventory.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/screenshot-4.png"
        alt="A mobile web browser showing someone doing a voice search for &amp;ldquo;Chateau la Bridane Saint Julien&amp;rdquo;, but it is transcribed as &amp;ldquo;shadow la brandon st julian&amp;rdquo;"
        title="Our prototype. This transcription mangles &lsquo;Chateau la Bridane Saint Julien&rsquo;, which still shows up in the top 5 results."
    />
    <figcaption class="text-center">Our prototype. This transcription mangles &lsquo;Chateau la Bridane Saint Julien&rsquo;, which still shows up in the top 5 results.</figcaption>
</figure>

</p>
<p>There are a bunch of obstacles to getting this kind of voice search to work well.</p>
<ul>
<li><strong>Getting accurate transcriptions, even with state of the art models, is hard.</strong> We have to deal with French and Italian wines, various accents, and restaurant background noise.</li>
<li><strong>Inaccurate transcriptions are different from typos and misspellings.</strong> Handling them requires a different set of tricks.</li>
<li><strong>The user&rsquo;s not at a keyboard.</strong> Auto-complete is an incredible feedback mechanism, but we don&rsquo;t have access to it.</li>
<li><strong>There is exactly one relevant record.</strong> A sommelier&rsquo;s search for the wine they&rsquo;re looking for has only one matching record. It <em>must</em> be a top result, otherwise the search failed.</li>
</ul>
<p>Perhaps the closest analog to this search use case is when you ask your phone to call a contact (e.g. &ldquo;Google, call Katie.&rdquo;). It turns out that this is pretty challenging!</p>
<p>Our goal was to validate feasibility and demand for a solution, so we needed to show something working well enough quickly.</p>
<p>I also wanted this to be easy to understand and operate: I didn&rsquo;t want to have to (re-)learn, configure, and tune Solr, ElasticSearch, or Typesense. Besides, I only had about 1,500 records and these tools aren&rsquo;t really designed to deal with the search use case I describe above, out of the box.</p>
<p>I managed to cobble together workable voice search in about 3 days using off-the-shelf components, a bag of information retrieval tricks, ~300 lines of Python, and a bunch of trial and error. It&rsquo;s imperfect but it&rsquo;s quick to implement, simple to operate, and it works with many classes of transcription errors with plenty of avenues for improvement.</p>
<ul>
<li><strong>Code:</strong> The codebase is <a href="https://github.com/voberoi/voice-search-with-whisper-duckdb-and-metaphone">on Github</a>.</li>
<li><strong>Demo:</strong> You can <a href="https://voice-search-with-whisper-duckdb-and-metaphone.streamlit.app/">give it a try on Streamlit Cloud</a>.</li>
<li><strong>Is this available? What happened next? Can you make this for me?</strong> I answer these questions <a href="#appendix">in the Appendix</a>.</li>
</ul>
<p>Here&rsquo;s how it works!</p>
<h2 id="a-summary-of-my-approach">A summary of my approach</h2>
<p>I use <a href="https://github.com/openai/whisper">OpenAI&rsquo;s Whisper</a> for audio transcription and <a href="https://duckdb.org/">DuckDB</a> to store and query my search index.</p>
<p><a href="https://duckdb.org/docs/extensions/full_text_search">DuckDB&rsquo;s full text search (FTS) extension</a> easily handles the happy path: accurate transcriptions. The FTS extension is easy to use, but it does not handle misspellings or, in my case, poorly-transcribed audio.</p>
<p>OpenAI&rsquo;s Whisper model is impressive, but:</p>
<ul>
<li>There is background noise in restaurants.</li>
<li>Non-native speakers pronounce French and Italian wines poorly.</li>
<li>Both of these issues affect audio clarity and, in turn, transcription quality.</li>
<li>Even without these issues, Whisper is not perfect.</li>
</ul>
<p>So my implementation needs to handle inaccurate transcriptions.</p>
<p>While there&rsquo;s plenty of prior art on handling misspellings in search, the same techniques don&rsquo;t work as well with bad audio transcriptions. This is because inaccurate transcriptions don&rsquo;t look like misspelled words. They often look like gibberish – made up words, or strings of words that sound like the audio but don&rsquo;t make sense together.</p>
<p>So I built two of my own search indexes in DuckDB to handle &ldquo;mistranscriptions&rdquo; using <a href="https://en.wikipedia.org/wiki/Metaphone">Metaphone</a>, a phonetic algorithm that maps similar-sounding words to the same pronunciation.</p>
<p>In total, I have 3 indexes: 1 full text search index on wine names, and 2 that use Metaphones, which I&rsquo;ll cover in greater detail below</p>
<p>At query time I do the following:</p>
<ol>
<li><strong>Query the indexes in 5 different ways.</strong> Each method excels at different kinds of queries or transcription errors. I get 10 results from each method, increasing the likelihood that the wine the user is looking for is in my result set.</li>
<li><strong>Rank all those results using text similarity.</strong> I get the Jaro-Winkler similarity score between each result and the transcription. This tends to bubble the result up to the top 5.</li>
<li><strong>Return the top 5 ranked results.</strong> I&rsquo;d say 80%+ of the time the result I was looking for ended up in the top 5, and it fared well with &ldquo;adversarial&rdquo; audio (comically bad accents, yelling in the background, etc.)</li>
</ol>
<h2 id="what-do-inaccurate-transcriptions-look-like">What do inaccurate transcriptions look like?</h2>
<p>Inaccurate transcriptions are usually close to how the words actually sound.</p>
<p>Let&rsquo;s say we have a wine in our inventory called Chateau Champignon.</p>
<p>Here are some potential transcriptions of &ldquo;chateau&rdquo;:</p>
<ul>
<li>shadow</li>
<li>shatto</li>
<li>chat oh</li>
</ul>
<p>Here are some potential transcriptions of &ldquo;champignon&rdquo;:</p>
<ul>
<li>champagne on</li>
<li>shomp inyon</li>
<li>champ onion</li>
<li>sham pig non</li>
</ul>
<p>We may end up with any permutation of transcriptions for &ldquo;chateau&rdquo; and &ldquo;champignon&rdquo;. For example:</p>
<ul>
<li>shadow champagne on</li>
<li>chat oh shomp inyon</li>
<li>shatto sham pig non</li>
</ul>
<p>Traditional search techniques to handle misspellings fail spectacularly on these kinds of errors. None of them look like misspellings or typos.</p>
<p>But they do sound the same, and this is where phonetic algorithms come in.</p>
<h2 id="you-can-map-inaccurate-transcriptions-to-the-same-string-using-phonetic-algorithms">You can map inaccurate transcriptions to the same string using phonetic algorithms</h2>
<p>Phonetic algorithms map words to pronunciations.</p>
<p>Different phonetic algorithms have different goals in mind, but there is a whole class of them that is designed to map similar-sounding words to the same pronunciation.</p>
<p>The earliest one of these is called Soundex, which was developed in the late 1800&rsquo;s and patented in 1920, well before the advent of computing!</p>
<p>Why? To map similar-sounding surnames to each other for the US Census. The history here is really neat, and you can read <a href="https://medium.com/@lukehenryotwell/the-soundex-algorithm-d39c2f8d8756">this post by Luke Otwell</a> to learn more if you&rsquo;re interested.</p>
<p>In my implementation I used Metaphone, a successor to Soundex, for which there is an open-source Python implementation in <code>pyphonetics</code>.</p>
<p>Metaphone maps <code>chateau champignon</code> to <strong><code>XTXMPNN</code>.</strong></p>
<p>Here&rsquo;s how Metaphone maps our transcriptions of Chateau Champignon above:</p>
<p><strong>chateau</strong></p>
<ul>
<li><code>shadow</code>: <strong><code>XT</code></strong></li>
<li><code>chat oh</code>: <strong><code>XT</code></strong></li>
<li><code>shatto</code>: <strong><code>XT</code></strong></li>
</ul>
<p><strong>champignon</strong></p>
<ul>
<li><code>champagne on</code>: <strong><code>XMPNN</code></strong></li>
<li><code>shomp inyon</code>: <strong><code>XMPNYN</code></strong></li>
<li><code>champ onion</code>: <strong><code>XMPNN</code></strong></li>
<li><code>sham pig non</code>: <strong><code>XMPNN</code></strong></li>
</ul>
<p><strong>various permutations of the above</strong></p>
<ul>
<li><code>shadow champagne on</code>: <strong><code>XTXMPNN</code></strong></li>
<li><code>chat oh shomp inyon</code>: <strong><code>XTXMPNYN</code></strong></li>
<li><code>shatto sham pig non</code>: <strong><code>XTXMPNN</code></strong></li>
</ul>
<p>Almost all of these map directly to the same Metaphone as Chateau Champignon!</p>
<p>To put this insight to work, our code creates two additional indexes: the Exact Metaphone Index and the Metaphone Token Index. <a href="https://github.com/voberoi/voice-search-with-whisper-duckdb-and-metaphone/blob/main/index.py">These indexes are constructed in <code>index.py</code>.</a></p>
<h2 id="the-two-metaphone-based-indexes">The two Metaphone-based indexes</h2>
<p>Assume we have three wines in our inventory:</p>
<ol>
<li>Chateau Champignon</li>
<li>Cavalleri Collezione Esclusiva Chardonnay</li>
<li>Chateau la Bridane Saint Julien</li>
</ol>
<p>Here&rsquo;s how we construct our two Metaphone indexes on those wines.</p>
<h3 id="1-the-exact-metaphone-index">1. The Exact Metaphone Index</h3>
<p>In this index we map the Metaphone for each wine&rsquo;s full name to itself:</p>
<ul>
<li><code>XTXMPNN</code> → <code>Chateau Champignon</code></li>
<li><code>KFLRKLSNSKLSFXRTN</code> → <code>Cavalleri Collezione Esclusiva Chardonnay</code></li>
<li><code>XTLBRTNSNTJLN</code> → <code>Chateau la Bridane Saint Julien</code></li>
</ul>
<p>This index is just a 1-to-1 mapping. It allows us to reverse look up a metaphone to a wine&rsquo;s full name as it exists in our index.</p>
<h3 id="2-the-metaphone-token-index">2. The Metaphone Token Index</h3>
<p>In this index we tokenize each wine name, get the Metaphone for each token, and construct an index on those tokens.</p>
<p>Here&rsquo;s what the first step looks like:</p>
<ol>
<li><code>Chateau Champignon</code> → <code>XT XMPNN</code></li>
<li><code>Cavaelleri Collezione Esclusiva Chardonnay</code> → <code>KFLR KLSN ESKLSF XRTN</code></li>
<li><code>Chateau la Bridane Saint Julien</code> → <code>XT L BRTN SNT JLN</code></li>
</ol>
<p>Let&rsquo;s call these &ldquo;Metaphone strings&rdquo;. Each token in the string is the Metaphone for a token in the original wine name. These strings are what we index.</p>
<p>Assuming that 1, 2, 3 are IDs that map to the wines in the list above, the Metaphone Token Index looks like this:</p>
<ul>
<li><code>XT</code> → <code>[1, 3]</code></li>
<li><code>XMPNN</code> → <code>[1]</code></li>
<li><code>KFLR</code> → <code>[2]</code></li>
<li><code>KLSN</code> → <code>[2]</code></li>
<li>&hellip; and so on.</li>
</ul>
<p>Conveniently, this is just a regular full text search index on the Metaphone strings above. So we can use DuckDB&rsquo;s full-text search extension again, saving the work to write code to construct or query the index.</p>
<p>This index allows us to reverse look up Metaphones for individual words in our transcript.</p>
<p>For example, a speaker might say only &ldquo;champignon&rdquo;, for which we get an inaccurate transcription like &ldquo;shampinon&rdquo;. Or a speaker might say 3 words in a wine name, but two of them are transcribed entirely incorrectly while the third has a Metaphone match.</p>
<p>This index gives us the means to salvage an inaccurate transcript as long as there&rsquo;s a Metaphone match in it, somewhere.</p>
<h2 id="five-query-time-approaches">Five query-time approaches</h2>
<p>Combined, the following approaches are greater than the sum of their parts. Individually, they excel at dealing with different kinds of queries and transcription errors.</p>
<p><a href="https://github.com/voberoi/voice-search-with-whisper-duckdb-and-metaphone/blob/main/search.py">Each of the query methods is implemented in <code>search.py</code></a>.</p>
<p>Let&rsquo;s add one more wine to our index for the following explanations. There are now 4 wines in our inventory:</p>
<ol>
<li>Chateau Champignon</li>
<li>Cavalleri Collezione Esclusiva Chardonnay</li>
<li>Chateau la Bridane Saint Julien</li>
<li>Guy Breton Morgon</li>
</ol>
<p>Here are the five query approaches we use.</p>
<h3 id="1-duckdb-full-text-query">1. DuckDB Full-Text Query</h3>
<p>This was the first one I implemented, and it uses DuckDB&rsquo;s full-text search extension.</p>
<p>Under the hood, DuckDB&rsquo;s FTS extension implements BM-25, a widely-used ranking function.</p>
<p>It constructs the index with some basic defaults for English: a list of 571 stopwords, the Porter stemmer, lowercasing all text, ignoring all non-alphabetic lowercase characters, and stripping accents.</p>
<p><strong>Pros</strong></p>
<ul>
<li>BM-25&rsquo;s ranking algorithm performs well: a query for &ldquo;chateau champignon&rdquo; will rank Chateau Champignon first, followed by the other hundred that include &ldquo;chateau&rdquo;.</li>
<li>It works well as long as the most unique words of the wine name are transcribed accurately. For example, &ldquo;shadow champignon&rdquo; will likely return &ldquo;Chateau Champignon&rdquo;, but &ldquo;chateau shampinyon&rdquo; is a crapshoot because there are hundreds of wines with &ldquo;chateau&rdquo; in their name.</li>
</ul>
<p><strong>Cons</strong></p>
<ul>
<li>It fails spectacularly on inaccurate transcriptions, usually returning zero or irrelevant results.</li>
</ul>
<h3 id="2-exact-metaphone-query">2. Exact Metaphone Query</h3>
<p>Here, we do a reverse lookup for the transcription&rsquo;s Metaphone in the Exact Metaphone Index I describe above:</p>
<ol>
<li><strong>Get transcription metaphone:</strong> &ldquo;shadow champagne on&rdquo; yields <code>XTXMPNN</code></li>
<li><strong>Reverse-lookup:</strong> <code>XTXMPNN</code> yields the record for Chateau Champignon.</li>
</ol>
<p><strong>Pros</strong></p>
<ul>
<li>Works with inaccurate transcriptions.</li>
<li>High <a href="https://en.wikipedia.org/wiki/Precision_and_recall"><em>precision</em></a>: it tends to match the exact wine the user is searching for, and nothing else.</li>
</ul>
<p><strong>Cons</strong></p>
<ul>
<li>The user must say the entire wine as it exists in our index. If they just say &ldquo;champignon&rdquo; instead of &ldquo;chateau champignon&rdquo;, we are out of luck.</li>
<li>Fails when a transcription&rsquo;s Metaphone does not match. An example is the Metaphone for &ldquo;shadow shomp inyon&rdquo;: <code>XTXMPNYN</code>, which has a &ldquo;Y&rdquo;.</li>
</ul>
<h3 id="3-metaphone-token-query">3. Metaphone Token Query</h3>
<p>Here, we first form our query. Let&rsquo;s say the speaker is retrieving <em>Chateau la Bridane Saint Julien.</em></p>
<p>They say only &ldquo;julien bridane&rdquo;, but they incorrectly pronounce each word. It is transcribed as <code>&quot;julian breedan&quot;</code>.</p>
<ol>
<li><strong>Tokenize the transcript:</strong> <code>&quot;julian breedan&quot;</code> becomes <code>[&quot;julian&quot;, &quot;breedan&quot;]</code></li>
<li><strong>Get the Metaphone for each token:</strong> <code>[&quot;julian, &quot;breedan&quot;]</code> becomes <code>[&quot;JLN&quot;, &quot;BRTN&quot;]</code></li>
<li><strong>Join the Metaphones:</strong> <code>[&quot;JLN&quot;, &quot;BRTN&quot;]</code> becomes <code>&quot;JLN BRTN&quot;</code></li>
</ol>
<p>Then we query the Metaphone Token Index <a href="#2-the-metaphone-token-index">described above</a> with our query.</p>
<p>Because our query contains <code>&quot;JLN&quot;</code> and <code>&quot;BRTN&quot;</code>, the wine <em>Chateau la Bridane Saint Julien</em> will rank highly. Its tokens map to: <code>[&quot;XT&quot;, &quot;L&quot;, &quot;BRTN&quot;, &quot;SNT&quot;, &quot;JLN&quot;]</code>.</p>
<p><em>Guy Breton Morgon</em> will also rank. Its tokens map to <code>[&quot;K&quot;, &quot;BRTN&quot;, &quot;MRKN&quot;]</code>.</p>
<p>Recall that we built this index using DuckDB&rsquo;s full-text search extension, so we query it with the extension too.</p>
<p><strong>Pros</strong></p>
<ul>
<li>Works with inaccurate transcriptions.</li>
<li>Works when speakers partially say the name of a wine.</li>
<li>Works when the order of the words spoken do not match the wine&rsquo;s record.</li>
</ul>
<p><strong>Cons</strong></p>
<ul>
<li>This approach sometimes yields many irrelevant results that have similar-sounding words in their names.</li>
<li>Fails when a transcription&rsquo;s Metaphones do not match.</li>
</ul>
<h3 id="4-similar-token-metaphones-query">4. &ldquo;Similar Token Metaphones&rdquo; Query</h3>
<p>Methods 1 to 3 handle:</p>
<ul>
<li>Accurate transcriptions</li>
<li>Inaccurate transcriptions with matching Metaphones</li>
</ul>
<p>This takes us far! But I would still get transcriptions without any matching Metaphones and I wanted to handle those.</p>
<p>I attempted a number of approaches, all using <a href="https://medium.com/@ethannam/understanding-the-levenshtein-distance-equation-for-beginners-c4285a5604f0">Levenshtein edit distance</a> in creative ways on increasingly worse transcriptions. <strong><em>All of them worked.</em></strong></p>
<p>The catch? They also introduced a lot of noise: the worse a transcription, the more permissive I had to be, which meant that my result set would also include lots of wines that looked and sounded nothing like the wine I was retrieving.</p>
<p>In information retrieval parlance, my search <a href="https://en.wikipedia.org/wiki/Precision_and_recall"><em>recall</em></a>suffered: I was retrieving an ever-higher percentage of irrelevant records.</p>
<p>I had a prototype to ship, so I settled with a basic method that:</p>
<ol>
<li>Didn&rsquo;t return a torrent of random records.</li>
<li>Could handle one extra, absent, or incorrect consonant that caused a token to generate a different Metaphone. For example, &ldquo;bribane&rdquo; maps to B<em>RBN</em>, while &ldquo;bridane&rdquo; – a token in our index – maps to <em>BRTN.</em></li>
</ol>
<p>Here&rsquo;s how it works. Let&rsquo;s say the speaker is retrieving <em>Chateau la Bridane Saint Julien</em> (Metaphones: <strong><code>XT L BRTN SNT JLN</code>*</strong>).*</p>
<p>They say &ldquo;bridane saint julien&rdquo;, but we get a really bad transcription: <code>&quot;brenton sane julia&quot;</code>, whose tokens map to <code>[&quot;**BRNTN**&quot;, &quot;**SN**&quot;, &quot;**JL**&quot;]</code>.</p>
<p>We&rsquo;re out of luck here with methods 1 to 3. Each will return zero results because we have no matching tokens nor matching Metaphones.</p>
<p>A &ldquo;Similar Token Metaphone&rdquo; query does the following:</p>
<ol>
<li><strong>Tokenize the transcription:</strong> <code>&quot;brenton sane julia&quot;</code> turns into <code>[&quot;brenton&quot;, &quot;sane&quot;, &quot;julia&quot;]</code>.</li>
<li><strong>Get all tokens in our index that are exactly 1 edit distance away from each token in our query:</strong> for <code>&quot;brenton&quot;</code> this is <code>[&quot;breton&quot;]</code>, for <code>&quot;sane&quot;</code> this is <code>[]</code> (an empty set), for <code>&quot;julia&quot;</code> this is <code>[]</code> (an empty set). So we get <code>[&quot;breton&quot;]</code>.</li>
<li><strong>Join all tokens from step 2:</strong> <code>[&quot;breton&quot;]</code> becomes <code>&quot;breton&quot;</code>. If we had more than one token, we&rsquo;d join all of them with a space.</li>
<li><strong>Use the string from step 3 to run a Metaphone Token Query.</strong> Our input to the query method <a href="#3-metaphone-token-query">described above</a> is <code>&quot;breton&quot;</code>.</li>
</ol>
<p>Because the Metaphone for <code>&quot;breton&quot;</code> is <code>BRTN</code> we get two records, one of which is our target wine:</p>
<ul>
<li><em>Chateau la Bridane Saint Julien</em> (Metaphones: <code>XT L BRTN SNT JLN</code>)</li>
<li><em>Guy Breton Morgon</em> (Metaphones: <code>K BRTN MRKN</code>)</li>
</ul>
<p>Using a Metaphone Token Query in Step 4 instead of a DuckDB Full-Text Query casts a slightly wider net, which increases the likelihood that we catch the result we want when all our other query methods have failed.</p>
<p>In my limited observation, it worked spookily well without returning lots of irrelevant results. It is a total delight to read an awful transcription like &ldquo;brenton sane julia&rdquo; after you speak yet have it retrieve the record you&rsquo;re looking for.</p>
<p><strong>Pros</strong></p>
<ul>
<li>Might succeed even when there are no matching Metaphones in a transcription.</li>
</ul>
<p><strong>Cons</strong></p>
<ul>
<li>Might return a lot of irrelevant results.</li>
</ul>
<h3 id="5-metaphone-substring-query">5. &ldquo;Metaphone Substring&rdquo; Query</h3>
<p>I added this final query approach to handle exactly one class of transcription error: transcriptions that break one word into multiple words. This happened fairly frequently.</p>
<p>Let&rsquo;s say the speaker is retrieving <em>Chateau Champignon,</em> they say &ldquo;champignon&rdquo;, and the transcription we receive is <code>&quot;champ onion&quot;</code>.</p>
<p>Query approaches 1 through 4 all fail:</p>
<ol>
<li>There is no wine that matches the tokens <code>&quot;champ&quot;</code> or <code>&quot;onion&quot;</code>.</li>
<li>The Exact Metaphone for <code>&quot;champ onion&quot;</code> is <code>XMPNN</code>, for which there is no match in the Exact Metaphone Index.</li>
<li>The Metaphone Tokens for <code>&quot;champ onion&quot;</code> are <code>XMP</code> and <code>ONN</code>, for which there are no matches in the Metaphone Token Index.</li>
<li>There are no tokens 1 edit distance away from <code>&quot;champ&quot;</code> and <code>&quot;onion&quot;</code></li>
</ol>
<p>It turns out that many of these cases have Exact Metaphones that are substrings of their target wine. In this case:</p>
<ul>
<li>The Exact Metaphone for <code>&quot;champ onion&quot;</code> is <code>XMPNN</code>.</li>
<li>The Exact Metaphone for <code>&quot;chateau champignon&quot;</code> is <code>XT XMPNN</code>.</li>
</ul>
<p>&hellip; so a substring search in our Exact Metaphone Index succeeds where all our other query methods have failed.</p>
<p><strong>Pros</strong></p>
<ul>
<li>It&rsquo;s simple.</li>
<li>Handles a transcription error that happens frequently.</li>
<li>Doesn&rsquo;t return a lot of irrelevant results.</li>
</ul>
<p><strong>Cons</strong></p>
<ul>
<li>Doesn&rsquo;t help with anything other than this one class of error.</li>
<li>Doesn&rsquo;t always work: &ldquo;shomp inyon&rdquo; maps to &ldquo;XMPNYN&rdquo;.</li>
</ul>
<h2 id="ranking-results-from-all-five-approaches-using-jaro-winkler-similarity">Ranking results from all five approaches using Jaro-Winkler similarity</h2>
<p>When we execute all five of our query approaches:</p>
<ul>
<li>We don&rsquo;t know which approach, if any, has the result the speaker is looking for.</li>
<li>Each approach might return multiple results.</li>
</ul>
<p>So how do we surface the target wine from all these results?</p>
<p>It&rsquo;s safe to assume that the transcript we receive is similar, by some measure, to the speaker&rsquo;s target wine. If it&rsquo;s not, then the transcript is probably too inaccurate – would a human be able to figure out which wine the sommelier is referring to?</p>
<p>The challenge here is determining an objective measure that consistently brings the target wine into our top 5 results. Doing this well requires data from real users and time to experiment, neither of which we had.</p>
<p>Aiming for a quick solution, I tried using text similarity using <a href="https://duckdb.org/docs/sql/functions/char#text-similarity-functions">whatever was available off-the-shelf using DuckDB.</a></p>
<p>It turns out that Jaro-Winkler similarity works well enough for our purposes.</p>
<p>My implementation:</p>
<ol>
<li>Gets up to 10 results from every query approach.</li>
<li>De-duplicates the set of results, because the same result might appear from multiple approaches.</li>
<li>Calculates Jaro-Winkler similarity between each result and the transcription.</li>
<li>Returns the top 5 ranked results.</li>
</ol>
<p>This is a workable implementation, but if you play around with the demo you&rsquo;ll observe it fail in basic ways, like ranking the target wine 5th when it seems implausible that the 1st, 2nd, 3rd, or 4th results are correct.</p>
<p>The problem with using text similarity is that we&rsquo;re discarding everything we know about our query approaches and the kinds of results they return. For example, if we get results from a DuckDB full-text search, it means the transcript contains correctly-spelled tokens in our search index, which increases the probability that we have an accurate transcript. That&rsquo;s information we could use to rank our search results!</p>
<h2 id="how-can-this-be-improved">How can this be improved?</h2>
<p>With lots of experimentation! It helps enormously to have data from real users searching for wines.</p>
<p>In a real setting, I&rsquo;d want to:</p>
<ul>
<li>Know what percentages of searches are successful over time.</li>
<li>Have a set of (transcript, target wine) pairs that I can test against.</li>
<li>Run A/B tests pitting search algorithms against each other.</li>
</ul>
<p>Alas, <a href="#appendix">we did not promote this prototype to production</a>.</p>
<p>If I spent more time on this voice search implementation, here are some of the things I might try.</p>
<p><strong>Ranking results: scores, weights, and phonetic algorithms</strong></p>
<ul>
<li>Weight results from query approaches differently.</li>
<li>Come up with a way to score results phonetically.</li>
<li>Give higher scores to results if they surface in multiple approaches.</li>
<li>Use different, or multiple phonetic algorithms for the task.</li>
</ul>
<p><strong>Using LLMs</strong></p>
<ul>
<li>Use an LLM to surface the likeliest wines. I tried this briefly and couldn&rsquo;t get it to work well, but it has promise.</li>
<li>Use an LLM to generate plausible mis-transcriptions of every wine. (GPT-4 generates great, plausible transcriptions). Index all of these.</li>
<li>Use the mis-transcriptions from the above to fine-tune an LLM to do this task.</li>
</ul>
<h2 id="appendix">Appendix</h2>
<h3 id="is-this-product-live-if-not-will-it-be">Is this product live? If not, will it be?</h3>
<p>No, and probably not. Everyone hates inventory counts, but this was not a pressing enough problem for the GM at Ristorante or the 10 wine stores, meat/cheese shops, and restaurants I spoke with.</p>
<p>In the time following the week we spent building our initial prototype, we did not gather enough evidence for us to continue working on this product.</p>
<h3 id="can-you-build-this-or-something-else-for-us">Can you build this, or something else, for us?</h3>
<p>Yes.</p>
<p>I co-own <a href="https://baxterhq.com">Baxter</a>, a product development and contracting company, with Gabriel Garrido. We&rsquo;re a two-person team of seasoned engineers and we&rsquo;ve built software and teams at numerous early stage startups.</p>
<p><em>Thanks to Gabriel Garrido, Phil Eaton, Pierre Jambet, Andy O&rsquo;Neill, Chuck Groom, and Aaron Rosen.</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Funnel analysis in SQL using window functions, range frames, and regular expressions</title>
      <link>https://vikramoberoi.com/posts/funnel-analysis-in-sql-using-window-functions-range-frames-and-regular-expressions/</link>
      <pubDate>Wed, 22 Feb 2023 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/funnel-analysis-in-sql-using-window-functions-range-frames-and-regular-expressions/</guid>
      <description>👋&#xA;I wrote this while investigating a product opportunity in the product analytics space.&#xA;We started and stopped this work in the first half of 2023, but I&amp;rsquo;m leaving this here for posterity.&#xA;Related posts:&#xA;Introducing Baxter: deep-dive event analytics on your data warehouse Baxter&amp;rsquo;s free funnel analysis SQL generation tool is out! All these posts were on our product blog.&#xA;A few weeks ago we released Baxter&amp;rsquo;s free funnel analysis SQL generator (no longer available) supporting nine different SQL dialects.</description>
      <content:encoded><![CDATA[<div class="bg-featured callout p-6 flex items-start gap-5 my-8">
    
    <p class="text-xl">👋</p>
    
    <div class="my-0"><p>I wrote this while investigating a product opportunity in the product analytics space.</p>
<p>We started and stopped this work in the first half of 2023, but I&rsquo;m leaving this here for posterity.</p>
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="https://vikramoberoi.com/posts/introducing-baxter-deep-dive-event-analytics-on-your-data-warehouse/">Introducing Baxter: deep-dive event analytics on your data warehouse</a></li>
<li><a href="https://vikramoberoi.com/posts/baxters-free-funnel-analysis-sql-generation-tool-is-out/">Baxter&rsquo;s free funnel analysis SQL generation tool is out!</a></li>
</ul>
<p>All these posts were on our product blog.</p>
</div>
</div>

<p>A few weeks ago we released <del>Baxter&rsquo;s free funnel analysis SQL generator</del> (no longer available) supporting nine different SQL dialects. You can read about how the tool works and the assumptions we make about your data in <a href="https://vikramoberoi.com/posts/baxters-free-funnel-analysis-sql-generation-tool-is-out/">this introductory blog post</a></p>
<p>We encourage you to copy/paste the queries our tool generates for your use cases! Funnel analysis queries are tough to write and the ones we generate are well-tested and will save you time.</p>
<p>But we&rsquo;re also proponents for understanding how the code you&rsquo;re using actually works. In that spirit, we&rsquo;re going to share the ideas behind the query strategies we employ for users and SQL enthusiasts alike.</p>
<p>This is is the first post in a series. In it, I&rsquo;m going to talk about how to write funnel analysis queries using three modern, common SQL features:</p>
<ul>
<li>Window functions</li>
<li><strong>Range frames</strong> (instead of <strong>row frames</strong>)</li>
<li>Regular expressions</li>
</ul>
<p>These queries can analyze <strong>loosely ordered</strong> funnels with a <strong>total sequence</strong> time constraint. These terms are <a href="https://vikramoberoi.com/posts/baxters-free-funnel-analysis-sql-generation-tool-is-out/">defined here</a>.</p>
<p>We use this strategy to generate funnel analysis queries for BigQuery, Spark, ClickHouse, DuckDB, PostgreSQL, and MySQL.</p>
<p><strong>Idea #1: You can use regular expressions to match funnels if you collapse sequences of events into strings.</strong></p>
<p>Here&rsquo;s how you might collapse a sequence of events into a string using DuckDB&rsquo;s SQL dialect:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">select</span>
</span></span><span style="display:flex;"><span>    user_id,
</span></span><span style="display:flex;"><span>    string_agg(
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">case</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;View Product&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;A&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Add to Cart&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;B&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Place Order&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;C&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">end</span>
</span></span><span style="display:flex;"><span>    ) <span style="color:#ca9ee6">as</span> event_sequence
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">from</span> events
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">where</span> ...
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">group</span> <span style="color:#ca9ee6">by</span> user_id
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- user_id | event_sequence
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- ------------------------
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">--       1   &#39;ABAAC&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">--       2   &#39;AAAAAAB&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- ...
</span></span></span></code></pre></div><p>If our funnel steps are “View Product” → “Add to Cart” → “Place Order”, the query above collapses each event into single-character identifiers and turns our funnel steps into “A” → “B” → “C”.</p>
<p>With strings of event sequences, using regular expressions to match how far users have progressed through a funnel is trivial and efficient.</p>
<p>The following query assumes we have a table called <code>event_sequences</code> with columns <code>user_id</code> and <code>event_sequence</code>, generated by our query above.</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ca9ee6">select</span>
</span></span><span style="display:flex;"><span>    <span style="color:#737994;font-style:italic">-- If there&#39;s an A, a user progressed to step 1.
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span>    (<span style="color:#ca9ee6">select</span> <span style="color:#ca9ee6">count</span>(<span style="color:#99d1db;font-weight:bold">*</span>)
</span></span><span style="display:flex;"><span>     <span style="color:#ca9ee6">from</span> event_sequences
</span></span><span style="display:flex;"><span>     <span style="color:#ca9ee6">where</span> regexp_match(event_sequence, <span style="color:#a6d189">&#39;A&#39;</span>) <span style="color:#ca9ee6">as</span> step_1_count,
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#737994;font-style:italic">-- If B eventually follows A, a user progressed to step 2.
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span>    (<span style="color:#ca9ee6">select</span> <span style="color:#ca9ee6">count</span>(<span style="color:#99d1db;font-weight:bold">*</span>)
</span></span><span style="display:flex;"><span>     <span style="color:#ca9ee6">from</span> event_sequences
</span></span><span style="display:flex;"><span>     <span style="color:#ca9ee6">where</span> regexp_match(event_sequence, <span style="color:#a6d189">&#39;A.*B&#39;</span>) <span style="color:#ca9ee6">as</span> step_2_count,
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#737994;font-style:italic">-- ... and if C eventually follows B, a user progressed to step 3.
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span>    (<span style="color:#ca9ee6">select</span> <span style="color:#ca9ee6">count</span>(<span style="color:#99d1db;font-weight:bold">*</span>)
</span></span><span style="display:flex;"><span>     <span style="color:#ca9ee6">from</span> event_sequences
</span></span><span style="display:flex;"><span>     <span style="color:#ca9ee6">where</span> regexp_match(event_sequence, <span style="color:#a6d189">&#39;A.*B.*C&#39;</span>) <span style="color:#ca9ee6">as</span> step_3_count,
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">from</span> event_sequences
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- step_1_count | step_2_count | step_3_count
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- ------------------------------------------
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">--          142             93             20
</span></span></span></code></pre></div><p>Note that our regular expressions permit any other events to occur between each funnel steps. This satisfies our <a href="https://vikramoberoi.com/posts/baxters-free-funnel-analysis-sql-generation-tool-is-out/"><strong>loose ordering</strong> constraint</a>.</p>
<p>Recall that we need to consider two important constraints:</p>
<ol>
<li>All the events must occur within a specified period: a start and end time.</li>
<li>All the events must occur within a specified duration. For example, all events A, B, and C might need to occur in a 24 hour period in our analysis. (This is our <a href="https://vikramoberoi.com/posts/baxters-free-funnel-analysis-sql-generation-tool-is-out/"><strong>total sequence</strong> time constraint</a>.)</li>
</ol>
<p>#1 is easy &ndash; you can filter for events that fall within the start and end time in your where clause.</p>
<p>#2 is trickier and where window functions with range frames come into play.</p>
<p><strong>Idea #2: Assemble a sliding window of events that occur within your funnel&rsquo;s duration by using window functions and range frames.</strong></p>
<div class="bg-featured callout p-6 flex items-start gap-5 my-8">
    
    <p class="text-xl">👉</p>
    
    <div class="my-0">Familiarity with window functions will help you follow this section of the post. If you need a quick primer I&rsquo;m partial to DuckDB&rsquo;s excellent documentation on <a href="https://duckdb.org/docs/sql/window_functions">this powerful SQL feature here</a>.</div>
</div>

<p>Let&rsquo;s assume these are our funnel analysis parameters:</p>
<ol>
<li>The events must occur in the month of January 2023.</li>
<li>Our funnel steps are: <strong>View Product</strong>, <strong>Add to Cart</strong>, and <strong>Place Order</strong>.</li>
<li>The funnel duration is 24 hours.</li>
</ol>
<p>Let&rsquo;s start with the following query, which helps us satisfy parameters #1 and #2 and collapses event sequences into strings. We’ll build on it next to satisfy parameter #3.</p>
<p>This query is written in DuckDB&rsquo;s SQL dialect.</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ca9ee6">select</span>
</span></span><span style="display:flex;"><span>    user_id,
</span></span><span style="display:flex;"><span>    string_agg(
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">case</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;View Product&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;A&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Add to Cart&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;B&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Place Order&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;C&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">end</span>
</span></span><span style="display:flex;"><span>    ) <span style="color:#ca9ee6">as</span> event_sequence
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">from</span> events
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">where</span> event_timestamp <span style="color:#99d1db;font-weight:bold">&gt;</span> <span style="color:#a6d189">&#39;2023-01-01&#39;</span> <span style="color:#ca9ee6">and</span>
</span></span><span style="display:flex;"><span>      event_timestamp <span style="color:#99d1db;font-weight:bold">&lt;</span> <span style="color:#a6d189">&#39;2023-02-01&#39;</span> <span style="color:#ca9ee6">and</span>
</span></span><span style="display:flex;"><span>      (event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;View Product&#39;</span> <span style="color:#ca9ee6">or</span>
</span></span><span style="display:flex;"><span>       event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Add to Cart&#39;</span> <span style="color:#ca9ee6">or</span>
</span></span><span style="display:flex;"><span>       event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Place Order&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">group</span> <span style="color:#ca9ee6">by</span> user_id;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- user_id | event_sequence
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- ------------------------
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">--       1   &#39;ABAAC&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">--       2   &#39;AAAAAAB&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- ...
</span></span></span></code></pre></div><p>We&rsquo;re not doing anything fancy yet. This query assembles event sequences that:</p>
<ul>
<li>&hellip; match our funnel steps (View Product, Add to Cart, and Place Order)</li>
<li>&hellip; and occur within the period that we&rsquo;re analyzing (Jan 2023)</li>
</ul>
<p>If we used regular expressions to count users who progressed through our funnel using the query results above, our counts would be incorrect. We have strings of event sequences, but <strong>there is no guarantee that these event sequences occurred within a 24-hour period.</strong></p>
<p>The following query (using DuckDB&rsquo;s SQL dialect) builds on the query above using window functions and range frames to make that guarantee.</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ca9ee6">select</span>
</span></span><span style="display:flex;"><span>    user_id,
</span></span><span style="display:flex;"><span>    string_agg(
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">case</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;View Product&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;A&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Add to Cart&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;B&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Place Order&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;C&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">end</span>
</span></span><span style="display:flex;"><span>    ) over funnel_duration <span style="color:#ca9ee6">as</span> preceding_event_sequence,
</span></span><span style="display:flex;"><span>    <span style="color:#ca9ee6">case</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;View Product&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;A&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Add to Cart&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;B&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">when</span> event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Place Order&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#ca9ee6">then</span> <span style="color:#a6d189">&#39;C&#39;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ca9ee6">end</span> <span style="color:#ca9ee6">as</span> event_identifier
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">from</span> events
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">where</span> event_timestamp <span style="color:#99d1db;font-weight:bold">&gt;</span> <span style="color:#a6d189">&#39;2023-01-01&#39;</span> <span style="color:#ca9ee6">and</span>
</span></span><span style="display:flex;"><span>      event_timestamp <span style="color:#99d1db;font-weight:bold">&lt;</span> <span style="color:#a6d189">&#39;2023-02-01&#39;</span> <span style="color:#ca9ee6">and</span>
</span></span><span style="display:flex;"><span>      (event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;View Product&#39;</span> <span style="color:#ca9ee6">or</span>
</span></span><span style="display:flex;"><span>       event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Add to Cart&#39;</span> <span style="color:#ca9ee6">or</span>
</span></span><span style="display:flex;"><span>       event_name <span style="color:#99d1db;font-weight:bold">=</span> <span style="color:#a6d189">&#39;Place Order&#39;</span>)
</span></span><span style="display:flex;"><span>window funnel_duration <span style="color:#ca9ee6">as</span> (
</span></span><span style="display:flex;"><span>    partition <span style="color:#ca9ee6">by</span> user_id
</span></span><span style="display:flex;"><span>    <span style="color:#ca9ee6">order</span> <span style="color:#ca9ee6">by</span> event_timestamp
</span></span><span style="display:flex;"><span>    range <span style="color:#ca9ee6">between</span> <span style="color:#99d1db">interval</span> <span style="color:#ef9f76">24</span> hours preceding <span style="color:#ca9ee6">and</span>
</span></span><span style="display:flex;"><span>                  <span style="color:#ca9ee6">current</span> <span style="color:#ca9ee6">row</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- user_id | preceding_event_sequence | event_identifier
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- -----------------------------------------------------
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">--       1   &#39;&#39;                         &#39;A&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">--       1   &#39;A&#39;                        &#39;B&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">--       1   &#39;AB&#39;                       &#39;A&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">--       1   &#39;ABA&#39;                      &#39;A&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">--       1   &#39;AA&#39;                       &#39;C&#39;
</span></span></span></code></pre></div><p>Unlike our first query, which had a row for every user, there exists a row for every event in this result set. And <code>preceding_event_sequence</code> contains <strong>all the events that occurred within the 24 hours preceding each event!</strong></p>
<p>Our first query showed that user 1&rsquo;s sequence of events over the course of January 2023 was <code>ABAAC</code>. Examine the <code>event_identifier</code> column in the results above and you&rsquo;ll find you can reconstruct user 1&rsquo;s <code>ABAAC</code> event sequence.</p>
<p>But from these results you&rsquo;ll also find:</p>
<ul>
<li>User 1 progressed through A and B within a 24 hour period.</li>
<li>User 1 <strong>did not</strong> progress through A, B, <strong>and</strong> C within a 24 hour period.</li>
</ul>
<p>The range frame in our window specification ensures that <code>preceding_event_sequence</code> maintains a sliding window of events within the 24 hours preceding each event:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span>...
</span></span><span style="display:flex;"><span>window funnel_duration <span style="color:#ca9ee6">as</span> (
</span></span><span style="display:flex;"><span>    partition <span style="color:#ca9ee6">by</span> user_id
</span></span><span style="display:flex;"><span>    <span style="color:#ca9ee6">order</span> <span style="color:#ca9ee6">by</span> event_timestamp
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#737994;font-style:italic">-- This is the range frame.
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span>    range <span style="color:#ca9ee6">between</span> <span style="color:#99d1db">interval</span> <span style="color:#ef9f76">24</span> hours preceding <span style="color:#ca9ee6">and</span>
</span></span><span style="display:flex;"><span>                  <span style="color:#ca9ee6">current</span> <span style="color:#ca9ee6">row</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>...
</span></span></code></pre></div><p>If you&rsquo;ve used window functions before, you&rsquo;re likely familiar with row frames. Row frames allow users to assemble a sliding window of <strong>some number of rows relative to the current row</strong>. Range frames are different: they allow users to assemble sliding windows of rows <strong>that satisfy some range of values relative to the current row</strong>.</p>
<p>In our case, those rows must have <code>event_timestamp</code> values within the 24 hours preceding our current row&rsquo;s <code>event_timestamp</code> value.</p>
<p>Window functions are already useful, but range frames make them wildly powerful. They might be my favorite modern SQL feature to-date.</p>
<p><strong>Assemble the rest of the query with your SQL bag-of-tricks: CTEs, aggregate functions, etc.</strong></p>
<p>The ideas above decompose our funnel analysis problem into two steps that get us most of the way:</p>
<ol>
<li>Collapse event sequences into strings to match funnels.</li>
<li>Only collect event sequences that occur within a 24 hour window.</li>
</ol>
<p>There are a lot of ways you might assemble your final result and they rely on the techniques you use writing everyday SQL. I won&rsquo;t be going into the details here, but you can <del>poke around our funnel SQL generator</del> (no longer available) to see how we do it.</p>
<p>But make no mistake: the SQL will be verbose, impossible to parameterize without using a template language like Jinja (used by DBT), and it won’t make for light bedtime reading.</p>
<p><strong>A note on our MySQL implementation</strong></p>
<p>Our MySQL implementation works with MySQL 8. But it&rsquo;s a bit of hack that uses undocumented behavior. This is not the case for any of our other implementations!</p>
<p>Let me explain.</p>
<p>Unlike other dialects, there is no string aggregation function that works in a windowing context in MySQL. <code>group_concat</code> aggregates values into a string, but it does not work as a window function so we can&rsquo;t use it to implement this query strategy.</p>
<p>MySQL doesn&rsquo;t have a native list or array data type, either! With ClickHouse and Spark, we&rsquo;re able to a) aggregate event sequences into a list, and then b) concatenate all elements in that array. So we can generate <code>['A', 'B', 'C']</code> and then turn that into <code>'ABC'</code>. We can&rsquo;t do that in MySQL.</p>
<p>MySQL does have a JSON type. And we can aggregate event sequences into a JSON array. These look like <code>['A', 'B', 'C']</code>, but there&rsquo;s no equivalent function for us to turn that into <code>'ABC'</code>.</p>
<p>Under the hood, however, MySQL&rsquo;s regular expression matching actually works on JSON arrays. It does so in a peculiar way &ndash; by matching against the string <code>[&quot;A&quot;, &quot;B&quot;, &quot;C&quot;]</code></p>
<p>Here&rsquo;s how that works:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- &#39;a&#39; eventually followed by &#39;b&#39; matches
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">select</span> REGEXP_LIKE(JSON_ARRAY(<span style="color:#a6d189">&#39;a&#39;</span>,<span style="color:#a6d189">&#39;b&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;d&#39;</span>), <span style="color:#a6d189">&#39;a.*b&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#99d1db;font-weight:bold">+</span><span style="color:#737994;font-style:italic">------------------------------------------------------+
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span><span style="color:#99d1db;font-weight:bold">|</span> REGEXP_LIKE(JSON_ARRAY(<span style="color:#a6d189">&#39;a&#39;</span>,<span style="color:#a6d189">&#39;b&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;d&#39;</span>), <span style="color:#a6d189">&#39;a.*b&#39;</span>) <span style="color:#99d1db;font-weight:bold">|</span>
</span></span><span style="display:flex;"><span><span style="color:#99d1db;font-weight:bold">+</span><span style="color:#737994;font-style:italic">------------------------------------------------------+
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span><span style="color:#99d1db;font-weight:bold">|</span>                                                    <span style="color:#ef9f76">1</span> <span style="color:#99d1db;font-weight:bold">|</span>
</span></span><span style="display:flex;"><span><span style="color:#99d1db;font-weight:bold">+</span><span style="color:#737994;font-style:italic">------------------------------------------------------+
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span><span style="color:#ef9f76">1</span> <span style="color:#ca9ee6">row</span> <span style="color:#ca9ee6">in</span> <span style="color:#ca9ee6">set</span> (<span style="color:#ef9f76">0</span>.<span style="color:#ef9f76">00</span> sec)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- &#39;b&#39; follows &#39;a&#39; in this array. But &#39;ab&#39; does not match!
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span>
</span></span><span style="display:flex;"><span>mysql<span style="color:#99d1db;font-weight:bold">&gt;</span> <span style="color:#ca9ee6">select</span> REGEXP_LIKE(JSON_ARRAY(<span style="color:#a6d189">&#39;a&#39;</span>,<span style="color:#a6d189">&#39;b&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;d&#39;</span>), <span style="color:#a6d189">&#39;ab&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#99d1db;font-weight:bold">+</span><span style="color:#737994;font-style:italic">----------------------------------------------------+
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span><span style="color:#99d1db;font-weight:bold">|</span> REGEXP_LIKE(JSON_ARRAY(<span style="color:#a6d189">&#39;a&#39;</span>,<span style="color:#a6d189">&#39;b&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;d&#39;</span>), <span style="color:#a6d189">&#39;ab&#39;</span>) <span style="color:#99d1db;font-weight:bold">|</span>
</span></span><span style="display:flex;"><span><span style="color:#99d1db;font-weight:bold">+</span><span style="color:#737994;font-style:italic">----------------------------------------------------+
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span><span style="color:#99d1db;font-weight:bold">|</span>                                                  <span style="color:#ef9f76">0</span> <span style="color:#99d1db;font-weight:bold">|</span>
</span></span><span style="display:flex;"><span><span style="color:#99d1db;font-weight:bold">+</span><span style="color:#737994;font-style:italic">----------------------------------------------------+
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span><span style="color:#ef9f76">1</span> <span style="color:#ca9ee6">row</span> <span style="color:#ca9ee6">in</span> <span style="color:#ca9ee6">set</span> (<span style="color:#ef9f76">0</span>.<span style="color:#ef9f76">00</span> sec)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- The string we&#39;re matching is actually:
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- [&#34;a&#34;, &#34;b&#34;, &#34;c&#34;, &#34;c&#34;, &#34;d&#34;]
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">--
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- So let&#39;s try matching &#39;a&#34;, &#34;b` instead.
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span>
</span></span><span style="display:flex;"><span>mysql<span style="color:#99d1db;font-weight:bold">&gt;</span> <span style="color:#ca9ee6">select</span> REGEXP_LIKE(JSON_ARRAY(<span style="color:#a6d189">&#39;a&#39;</span>,<span style="color:#a6d189">&#39;b&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;d&#39;</span>), <span style="color:#a6d189">&#39;a&#34;, &#34;b&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#99d1db;font-weight:bold">+</span><span style="color:#737994;font-style:italic">--------------------------------------------------------+
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span><span style="color:#99d1db;font-weight:bold">|</span> REGEXP_LIKE(JSON_ARRAY(<span style="color:#a6d189">&#39;a&#39;</span>,<span style="color:#a6d189">&#39;b&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;c&#39;</span>,<span style="color:#a6d189">&#39;d&#39;</span>), <span style="color:#a6d189">&#39;a&#34;, &#34;b&#39;</span>) <span style="color:#99d1db;font-weight:bold">|</span>
</span></span><span style="display:flex;"><span><span style="color:#99d1db;font-weight:bold">+</span><span style="color:#737994;font-style:italic">--------------------------------------------------------+
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span><span style="color:#99d1db;font-weight:bold">|</span>                                                      <span style="color:#ef9f76">1</span> <span style="color:#99d1db;font-weight:bold">|</span>
</span></span><span style="display:flex;"><span><span style="color:#99d1db;font-weight:bold">+</span><span style="color:#737994;font-style:italic">--------------------------------------------------------+
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span><span style="color:#ef9f76">1</span> <span style="color:#ca9ee6">row</span> <span style="color:#ca9ee6">in</span> <span style="color:#ca9ee6">set</span> (<span style="color:#ef9f76">0</span>.<span style="color:#ef9f76">00</span> sec)
</span></span></code></pre></div><p>I know this is hack! But:</p>
<ul>
<li>We suspect there is a small minority of users who are using MySQL for this task.</li>
<li>Our MySQL queries pass all our tests.</li>
<li>This strategy is significantly faster than our fallback strategy (self-joins).</li>
</ul>
<p>If you run into issues using our MySQL implementation, or you&rsquo;d like us to fall back to another strategy for it, please reach out to us.</p>
<p><strong>Why can&rsquo;t Redshift, Snowflake, or SQLite use this query strategy?</strong></p>
<p>Different reasons!</p>
<ul>
<li><strong>Redshift</strong> doesn&rsquo;t support range frames in window functions. Its dialect is ancient and stuck somewhere in Postgres 8, the database it&rsquo;s forked from.</li>
<li><strong>Snowflake</strong> has a rich, modern SQL dialect. But it doesn&rsquo;t doesn&rsquo;t support <strong>sliding</strong> range frames, which the window function/range frame strategy that we&rsquo;re using requires.</li>
<li><strong>SQLite</strong> actually supports this strategy! But it won&rsquo;t work out of the box. While SQLite has dedicated regular expression-matching syntax (where <val> REGEXP <regexp>), <a href="https://www.sqlite.org/lang_expr.html#the_like_glob_regexp_match_and_extract_operators">it actually just calls a function that users must supply</a>. Most implementations do not provide one, so we use our fallback strategy instead.</li>
</ul>
<p>Some observations on the above:</p>
<ul>
<li>It is frustrating how much <strong>Redshift</strong>&rsquo;s SQL dialect lacks in features.</li>
<li><strong>Snowflake</strong> has this one odd omission in its support for window ranges &ndash; but it does implement <code>MATCH_RECOGNIZE</code>, a powerful and expressive operator that&rsquo;s perfect for funnel analysis.</li>
<li>I am confused as to why <strong>SQLite</strong> dedicates language syntax to regular expression matching without giving users the facility to use it out of the box.</li>
</ul>
<p><strong>Next: stay tuned to learn how to use Snowflake&rsquo;s <code>MATCH_RECOGNIZE</code> to implement funnel analysis queries</strong></p>
<p>Snowflake&rsquo;s dialect is powerful. You can see <code>MATCH_RECOGNIZE</code> in action in our <del>funnel SQL query generator</del> (no longer available). I&rsquo;m excited to share how that works.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Baxter&#39;s free funnel analysis SQL generation tool is out!</title>
      <link>https://vikramoberoi.com/posts/baxters-free-funnel-analysis-sql-generation-tool-is-out/</link>
      <pubDate>Mon, 30 Jan 2023 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/baxters-free-funnel-analysis-sql-generation-tool-is-out/</guid>
      <description>Our funnel analysis SQL generator is significantly easier to use than this contraption. Credit: katiebcartoons.com 👋&#xA;I wrote this while investigating a product opportunity in the product analytics space.&#xA;We started and stopped this work in the first half of 2023, but I&amp;rsquo;m leaving this here for posterity.&#xA;Related posts:&#xA;Introducing Baxter: deep-dive event analytics on your data warehouse Funnel analysis in SQL using window functions, range frames, and regular expressions All these posts were on our product blog.</description>
      <content:encoded><![CDATA[<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/funnel-crank-contraption.png"
        alt="A cartoon of a person using a hand crank to deliver funnel parameters into a funnel."
        title="Our funnel analysis SQL generator is significantly easier to use than this contraption. Credit: katiebcartoons.com"
    />
    <figcaption class="text-center">Our funnel analysis SQL generator is significantly easier to use than this contraption. Credit: <a href="https://katiebcartoons.com">katiebcartoons.com</a></figcaption>
</figure>

</p>
<div class="bg-featured callout p-6 flex items-start gap-5 my-8">
    
    <p class="text-xl">👋</p>
    
    <div class="my-0"><p>I wrote this while investigating a product opportunity in the product analytics space.</p>
<p>We started and stopped this work in the first half of 2023, but I&rsquo;m leaving this here for posterity.</p>
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="https://vikramoberoi.com/posts/introducing-baxter-deep-dive-event-analytics-on-your-data-warehouse/">Introducing Baxter: deep-dive event analytics on your data warehouse</a></li>
<li><a href="https://vikramoberoi.com/posts/funnel-analysis-in-sql-using-window-functions-range-frames-and-regular-expressions/">Funnel analysis in SQL using window functions, range frames, and regular expressions</a></li>
</ul>
<p>All these posts were on our product blog.</p>
</div>
</div>

<p>Today we&rsquo;re releasing a free tool that generates multi-step funnel analysis queries in nine SQL dialects: <strong>BigQuery</strong>, <strong>Snowflake</strong>, <strong>Redshift</strong>, <strong>Spark</strong>, <strong>ClickHouse</strong>, <strong>DuckDB</strong>, <strong>PostgreSQL</strong>, <strong>MySQL</strong>, and <strong>SQLite</strong>.</p>
<p>It&rsquo;s free, it&rsquo;ll save you time and tears, and <del>it&rsquo;s available here</del> (no longer available).</p>
<p>Give it a spin! Reach out to us if you have requests or run into issues!</p>
<p>Implementing funnel analysis queries across nine SQL dialects will lead you into some pretty goofy corners of SQL implementations. Stay tuned to the Baxter blog for posts about query strategies we&rsquo;ve implemented and the SQL quirks that we made friends and enemies with along the way.</p>
<p>For now, let&rsquo;s get you situated.</p>
<p>The rest of this post outlines the assumptions our funnel SQL generator makes about your event data and the kinds of funnel analyses it enables.</p>
<p>I&rsquo;m also going to share a bit about the strategies we employ and how we test them so you can be confident in their results.</p>
<hr>
<p><strong>Table of contents</strong></p>
<ul>
<li><a href="#what-data-model-do-these-queries-require">What data model do these queries require?</a></li>
<li><a href="#what-kinds-of-funnel-analyses-do-these-queries-allow-me-to-do">What kinds of funnel analyses do these queries allow me to do?</a></li>
<li><a href="#nine-sql-dialects-three-funnel-query-strategies">Nine SQL dialects, three funnel query strategies</a></li>
<li><a href="#how-do-you-know-these-are-correct">How do you know these are correct?</a></li>
<li><a href="#questions-requests-issues">Questions? Requests? Issues?</a></li>
</ul>
<hr>
<h1 id="what-data-model-do-these-queries-require">What data model do these queries require?</h1>
<p>If you want these queries to work out of the box, you&rsquo;ll need an <code>events</code> table with at least these three 3 columns:</p>
<ul>
<li><code>user_id</code>: an identifier — the type won&rsquo;t actually matter</li>
<li><code>event_name</code>: the event&rsquo;s name as some textual type (TEXT, VARCHAR, etc.)</li>
<li><code>event_timestamp</code>: the time at which an event occurred as a TIMESTAMP or DATETIME-compatible type.</li>
</ul>
<p>If your table or column names differ, you can modify them in the SQL we generate. And if you have more columns, these queries will still work. In fact, that&rsquo;s a pretty common scenario!</p>
<p>You might already have a wide table of events if:</p>
<ul>
<li>You use <a href="https://github.com/snowplow/snowplow">Snowplow</a> to track your events.</li>
<li>You have <a href="https://support.google.com/analytics/answer/9358801?hl=en">GA4 writing data to BigQuery</a>.</li>
<li>You use one of <a href="https://segment.com/">Segment</a> or <a href="https://rudderstack.com/">Rudderstack</a>&rsquo;s data warehouse connectors.</li>
<li>You pay <a href="https://www.heap.io/">Heap</a>/<a href="https://amplitude.com/">Amplitude</a>/<a href="https://mixpanel.com/">Mixpanel</a>/<a href="https://posthog.com/">Posthog</a> to send raw event data to your warehouse.</li>
</ul>
<p>Some companies do custom event tracking or build data pipelines that materialize event data this way, too.</p>
<p>For example, a row in your <code>orders</code> table might turn into a row in your <code>events</code> table. If a user with id <code>23</code> placed an order on January 22nd, 2023, you might create this row in <code>events</code>: <code>23</code>, <code>Order Placed</code>, <code>2023-01-22 13:34:01</code>, <code>(various columns with order properties)</code></p>
<p>The are two primary benefits to modeling event data as a wide table with many columns:</p>
<ul>
<li><strong>One data model answers many questions:</strong> you can perform a lot of common product analytics queries with this data model.</li>
<li><strong>Performance:</strong> you prevent joins by having one table with all the dimensions and metrics you want to query.</li>
</ul>
<p>If you don&rsquo;t have a table of events, you&rsquo;ll need to massage your data so that it&rsquo;s structured this way.</p>
<p>Finally, you might have more complicated predicates in your funnel steps like <code>event_name == 'Pageview' and url_path LIKE '...'</code>. You can modify the queries we generate with your own predicates and remain confident that the query will yield accurate multi-step funnel conversion numbers.</p>
<h1 id="what-kinds-of-funnel-analyses-do-these-queries-allow-me-to-do">What kinds of funnel analyses do these queries allow me to do?</h1>
<p>You can analyze funnels…</p>
<ul>
<li>&hellip; with any number of steps</li>
<li>&hellip; that must happen within some duration</li>
<li>&hellip; where other events may happen in between each step</li>
</ul>
<div class="bg-featured callout p-6 flex items-start gap-5 my-8">
    
    <p class="text-xl">🤖</p>
    
    <div class="my-0">Our approach generalizes to any number of funnel steps, but we&rsquo;ve constrained our tool to 10 steps for now.</div>
</div>

<p>Another way to state the above is that these queries support funnel analyses with <strong>loose ordering</strong> and a <strong>total sequence</strong> time constraint.</p>
<p>They do not support <strong>strict ordering</strong> or a <strong>stepwise</strong> time constraint.</p>
<p>Credit for this terminology goes to <a href="https://twitter.com/teej_m">TJ Murphy</a> — a <a href="https://twitter.com/teej_m/status/1567622047739805696">certifiable</a> <a href="https://twitter.com/teej_m/status/1518110344559833088">funnel</a> <a href="https://twitter.com/teej_m/status/1527079058063781888">query</a> <a href="https://twitter.com/teej_m/status/1518392665800208384">expert</a> — who shared it with me some months ago. I don&rsquo;t know if this language is actually formalized anywhere.</p>
<p>Here&rsquo;s the difference between <strong>loose</strong> and <strong>strict</strong> ordering.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/loose-vs-strict-ordering.png"
        alt="A chart showing how to match funnels with loose vs. strict ordering, with four examples."
        title="Loose ordering allows events to occur between each step in your funnel. Strict ordering doesn&rsquo;t."
    />
    <figcaption class="text-center"><strong>Loose ordering</strong> allows events to occur between each step in your funnel. <strong>Strict ordering</strong> doesn&rsquo;t.</figcaption>
</figure>

</p>
<p>Here&rsquo;s the difference between <strong>total sequence</strong> and <strong>stepwise</strong> time constraints.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/total-sequence-vs-stepwise.png"
        alt="An image showing the different between total sequence and stepwise time constraints when analyzing funnels."
        title="Total sequence: all steps must complete within some duration. Stepwise: the time between steps must not exceed some duration."
    />
    <figcaption class="text-center"><strong>Total sequence</strong>: all steps must complete within some duration. <strong>Stepwise</strong>: the time between steps must not exceed some duration.</figcaption>
</figure>

</p>
<p>Growth marketers, product managers, and analysts most commonly want <strong>loose ordering</strong> and a <strong>total sequence</strong> time constraint when they perform funnel analyses. That&rsquo;s what our funnel query generator supports today.</p>
<p>Funnel queries that implement <strong>strict ordering</strong> and a <strong>stepwise</strong> time constraint are substantially different. We haven&rsquo;t implemented them yet but we&rsquo;ll work on it if folks are keen on it, so tell us if you are.</p>
<h1 id="nine-sql-dialects-three-funnel-query-strategies">Nine SQL dialects, three funnel query strategies</h1>
<p>At release we employ three funnel query strategies across nine SQL dialects.</p>
<p>If we don&rsquo;t support your SQL dialect and you&rsquo;d like for us to, we want to help!</p>
<p>These are the strategies we employ today:</p>
<ul>
<li><strong>Self-joins</strong>: this is our fallback for less full-featured dialects. We use it for Redshift and SQLite.</li>
<li><strong><code>MATCH_RECOGNIZE</code></strong>: our Snowflake funnel query strategy is based on <a href="https://docs.snowflake.com/en/sql-reference/constructs/match_recognize.html"><code>MATCH_RECOGNIZE</code></a>, a powerful feature unavailable in other dialects we support.</li>
<li><strong>Window functions, range frames, and regexes</strong>: we combine these techniques to implement funnel queries for every other dialect we support. (<a href="https://duckdb.org/docs/sql/window_functions#range-framing">Range frame specifications</a> might be my favorite modern SQL feature to-date.)</li>
</ul>
<p>Right now, our goal with Baxter&rsquo;s funnel SQL query generator is to help users solve their immediate problem: writing a funnel analysis query that just works.</p>
<p>While we haven&rsquo;t tested the performance behind these strategies, we know with certainty that one of them scales poorly: the self-join. We&rsquo;ll benchmark and improve the strategies we use for each dialect over time.</p>
<p>If you have an approach that works particularly well for your dialect and data warehouse, we&rsquo;d love to hear about it.</p>
<h1 id="how-do-you-know-these-are-correct">How do you know these are correct?</h1>
<p>We&rsquo;ve built a synthetic dataset that we run and check our queries against. We also have test harnesses that run generated queries against a data warehouse for every dialect we support.</p>
<p>We do a lot of automated testing!</p>
<p>If you come across a bug, please let us know and we&rsquo;ll get it fixed promptly.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Introducing Baxter: deep-dive event analytics on your data warehouse</title>
      <link>https://vikramoberoi.com/posts/introducing-baxter-deep-dive-event-analytics-on-your-data-warehouse/</link>
      <pubDate>Tue, 17 Jan 2023 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/introducing-baxter-deep-dive-event-analytics-on-your-data-warehouse/</guid>
      <description>👋&#xA;I wrote this while investigating a product opportunity in the product analytics space.&#xA;We started and stopped this work in the first half of 2023, but I&amp;rsquo;m leaving this here for posterity.&#xA;Related posts:&#xA;Baxter&amp;rsquo;s free funnel analysis SQL generation tool is out! Funnel analysis in SQL using window functions, range frames, and regular expressions All these posts were on our product blog.&#xA;I recently wrote about how I don&amp;rsquo;t like any of the funnel or cohort analysis tools available to me.</description>
      <content:encoded><![CDATA[<div class="bg-featured callout p-6 flex items-start gap-5 my-8">
    
    <p class="text-xl">👋</p>
    
    <div class="my-0"><p>I wrote this while investigating a product opportunity in the product analytics space.</p>
<p>We started and stopped this work in the first half of 2023, but I&rsquo;m leaving this here for posterity.</p>
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="https://vikramoberoi.com/posts/baxters-free-funnel-analysis-sql-generation-tool-is-out/">Baxter&rsquo;s free funnel analysis SQL generation tool is out!</a></li>
<li><a href="https://vikramoberoi.com/posts/funnel-analysis-in-sql-using-window-functions-range-frames-and-regular-expressions/">Funnel analysis in SQL using window functions, range frames, and regular expressions</a></li>
</ul>
<p>All these posts were on our product blog.</p>
</div>
</div>

<p>I recently wrote about how <a href="https://vikramoberoi.com/posts/i-dont-like-any-of-the-funnel-or-cohort-analysis-tools-available-to-me/">I don&rsquo;t like any of the funnel or cohort analysis tools available to me</a>.</p>
<p><a href="https://garrido.io">Gabriel</a> and I (<a href="https://vikramoberoi.com">Vikram</a>) are working on an alternative: Baxter.</p>
<video width=100% controls autoplay>
    <source src="video/demo.mp4" type="video/webm">
    Your browser does not support the video tag.  
</video>
<p>Everyone has event data today. Maybe you&rsquo;re collecting events via Snowplow, Segment, or Rudderstack. Or maybe you&rsquo;re using data from your application&rsquo;s database, which will usually be more trustworthy and critical to your app than events you instrument.</p>
<p>Companies pay a lot of money to send event data to third parties like <a href="https://heap.io">Heap</a>, <a href="https://amplitude.com">Amplitude</a>, <a href="https://mixpanel.com">Mixpanel</a>, and <a href="https://posthog.com">Posthog</a> (henceforth <strong>HAMP</strong>) so they can use their purpose-built event analysis tools. Not only is this unnecessary for most event analysis tasks, analysts commonly have to supplement insights from these tools anyway because the tools fall short or the data is suspect.</p>
<p><strong>HAMP</strong> has its place, especially in real-time product analytics, experimentation, and monitoring. Their tools are great at answering high-level questions about user activity. But they don&rsquo;t cater to analysts who are working on extracting valuable insight. All of that work is done in SQL, Excel, notebooks, and BI tools. Not in product analytics suites.</p>
<p>Our aim with Baxter is to help analysts follow lines of inquiry well beyond the point existing tools fall short. And we&rsquo;ll do all this in your organization&rsquo;s data warehouse, giving you the following advantages:</p>
<ul>
<li>You retain your data.</li>
<li>You don&rsquo;t chase inevitable discrepancies between your data and a third party&rsquo;s.</li>
<li>You can modify and use the queries Baxter generates in your existing workflows, giving you a gentle off-ramp when you <strong>do</strong> need to drop into SQL.</li>
</ul>
<p>SQL is up to the task for most product analytics questions. It does the job for most data sizes that teams work with, too. But it&rsquo;s challenging to write and parameterize correct, high-performing SQL queries for common event analyses.</p>
<p>Analysts shouldn&rsquo;t need to painstakingly write SQL in order to explore and analyze event data. Their only alternative shouldn&rsquo;t be to send their data to third parties and suffer tools that don&rsquo;t serve them or integrate with their workflows either.</p>
<p>Interested? We would love to speak with you. Join our waitlist or reach out to us on Twitter.</p>
]]></content:encoded>
    </item>
    <item>
      <title>I don’t like any of the funnel or cohort analysis tools available to me</title>
      <link>https://vikramoberoi.com/posts/i-dont-like-any-of-the-funnel-or-cohort-analysis-tools-available-to-me/</link>
      <pubDate>Tue, 27 Dec 2022 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/i-dont-like-any-of-the-funnel-or-cohort-analysis-tools-available-to-me/</guid>
      <description>I do like these silly funnels, though. Ten years ago, while starting the Data Engineering team at Harry’s, I wrote a suite of cohort analysis queries that looked more-or-less like the queries in this post.&#xA;The success of the razor and blades model hinges on high customer retention so we tracked it religiously from day one. Retention was so critical at Harry’s that I even wrote a haiku about it once.</description>
      <content:encoded><![CDATA[<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/IMG_3505.JPG"
        alt="Three funnels upside down decorated with googly eyes, pom-poms, and pipe cleaners to look like people. Behind them is a lunch box robot and a small diorama in a crate with a bunch of small ceramic friars and a disco ball."
        title="I do like these silly funnels, though."
    />
    <figcaption class="text-center">I do like these silly funnels, though.</figcaption>
</figure>

</p>
<p>Ten years ago, while starting the Data Engineering team at <a href="https://www.harrys.com/">Harry’s</a>, I wrote a suite of cohort analysis queries that looked more-or-less like <a href="https://www.lennysnewsletter.com/i/70174617/getting-retention-via-sql">the queries in this post.</a></p>
<p>The success of the razor and blades model hinges on high customer retention so we tracked it religiously from day one. Retention was so critical at Harry’s that I even wrote a haiku about it once.*</p>
<p>I wrote 15-20 permutations of cohort queries that we would analyze regularly. At Harry’s we cut our cohorts in a bunch of ways: the channel customers came from, the contents of their first order, age/gender, geography, etc. We analyzed account creation -&gt; 1st order, 1st order -&gt; 2nd order, 2nd order -&gt; 3rd order separately.</p>
<p>There were <em>a lot</em> of charts. We wanted to compare them and see how they changed week-over-week and month-over-month.</p>
<p>I could have sent our data to <a href="https://www.mixpanel.com">Mixpanel</a> at the time, but it didn’t allow us to explore our data this way. So I hand-rolled a lot of SQL queries and my colleague analyzed the data in Excel. It was cumbersome and time-consuming, but it was a critical analysis for the business.</p>
<p>This year I was retained as a fractional Head of Product for a long-time client and this summer I took a few days to dive into my client’s retention metrics. They have a loyal base of regular users and I wanted to figure out how they differed from folks who churned. So I reached for my trusty friend: the cohort analysis!</p>
<p>Imagine my surprise when my options were no different than they were a decade ago:</p>
<ol>
<li>I can send my data to tool like <a href="https://www.amplitude.com">Amplitude</a>, <a href="https://www.mixpanel.com">Mixpanel</a>, or <a href="https://www.heapanalytics.com">Heap</a> and use its tools.</li>
<li>I can write SQL and visualize data in Excel or a BI tool.</li>
</ol>
<p>I didn’t like either of these options ten years ago! I still don’t.</p>
<p>While product analytics suites provide useful tools for exploratory analyses, I usually need to drop down to SQL to answer common follow-up questions. And ensuring accuracy is challenging: the data that ends up in these tools is frequently suspect because event instrumentation is so brittle. Last week I came across Olga Berezovsky’s <a href="https://dataanalysis.substack.com/p/growth-loops-and-some-hard-truths">feedback on Amplitude after its most recent conference</a> and it resonates — Amplitude and its competitors don’t serve me well when I’m doing deeper analyses and I can’t rely on their accuracy.</p>
<p>Meanwhile, I love the flexibility I have when I’m writing SQL: I can query my data however I&rsquo;d like to. I can clean it up, run spot checks, and write data quality tests to make sure my analysis is correct. But SQL queries for funnel and cohort analyses are challenging to write correctly and scale. Exploring funnels and cohorts this way — asking follow-up questions like, “Why is our March cohort so much more active than our February cohort?” — is time-consuming and I miss out on the interactivity I get from product analytics tools.</p>
<p>I want to be able to analyze behavioral data with the affordances that existing product analytics tools provide. But I don’t want to be shackled to those tools.</p>
<p>The tool I want sits in a happy middle:</p>
<ul>
<li><strong>It’s interactive, like product analytics tools.</strong> I want to select parameters for my funnels and cohorts and get charts. I want to click around to answer follow-up questions.</li>
<li><strong>I can use the data I have.</strong> I don’t want to send the data to a third-party. I want to use the data I have in my application database, data warehouse, or on my local machine.</li>
<li><strong>It’s SQL-native and integrates with my workflows.</strong> I want to generate queries for 90+% of the charts I explore so I can use them in my BI tool, data pipeline, or notebook.</li>
<li><strong>I can go deep without having to write SQL.</strong> I want to be able to answer questions and explore data well beyond the point that product analytics tools fall short.</li>
</ul>
<p>I want this to exist! So I&rsquo;m working on it.</p>
<p>If you analyze behavioral data and the above resonates, I would be delighted to speak with you. <a href="https://vikramoberoi.com/contact">Please say hello!</a></p>
<p>*You can read about <a href="https://www.sandwichesimade.org/2019/10/24/a-recreation-of-honeyhole-s-el-guapo-sandwich.html">my pursuit of poetry in the workplace</a> on my secret sandwich blog. (Currently on hiatus.)</p>
]]></content:encoded>
    </item>
    <item>
      <title>Using Polars on results from DuckDB&#39;s Arrow interface in Rust</title>
      <link>https://vikramoberoi.com/posts/using-polars-on-results-from-duckdbs-arrow-interface-in-rust/</link>
      <pubDate>Sun, 04 Dec 2022 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/using-polars-on-results-from-duckdbs-arrow-interface-in-rust/</guid>
      <description>I found myself wanting to manipulate/compute a large Arrow result set from a DuckDB query in Rust. I first wrote code to iterate over these results and compute what I needed, but the result was a lot of code that ran slowly and was cumbersome to write. I decided to reach for Polars instead.&#xA;The end result is way less code that is much more performant. I also have the Polars API to work with on any result set from DuckDB, which lets me iterate more quickly.</description>
      <content:encoded><![CDATA[<p>I found myself wanting to manipulate/compute a large <a href="https://arrow.apache.org/">Arrow</a> result set from a <a href="https://duckdb.org/">DuckDB</a> query in Rust. I first wrote code to iterate over these results and compute what I needed, but the result was a <em>lot</em> of code that ran slowly and was cumbersome to write. I decided to reach for <a href="https://www.pola.rs/">Polars</a> instead.</p>
<p>The end result is way less code that is much more performant. I also have the Polars API to work with on any result set from DuckDB, which lets me iterate more quickly. It&rsquo;s handy!</p>
<p>That said, this was really painful for me to figure out. There are a lot of documentation gaps in these projects, I still don&rsquo;t quite understand the Arrow APIs, and I&rsquo;m new to Rust. If you&rsquo;re looking for a shortcut, I published a <a href="https://github.com/voberoi/duckdb-polars-rust">Github repo showing how you can glue these APIs together</a>.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/polar-duck-for-v.png"
        alt="A duck giving a polar bear an arrow. The bear is confused."
        title="This whole situation is very confusing. credit: katiebcartoons.com"
    />
    <figcaption class="text-center">This whole situation is very confusing. credit: <a href="https://www.katiebcartoons.com">katiebcartoons.com</a></figcaption>
</figure>

</p>
<p>If you just want to use these APIs, skip the rest of this post and visit the repo. I share more around how these APIs work, my journey to figuring it out, and questions that came up below.</p>
<p><strong>Querying DuckDB</strong></p>
<p>I started out using <a href="https://crates.io/crates/duckdb">duckdb-rs</a>, &ldquo;&hellip; an ergonomic wrapper for using <a href="https://github.com/duckdb/duckdb">duckdb</a> from Rust. It attempts to expose an interface similar to <a href="https://github.com/rusqlite/rusqlite">rusqlite</a>.&rdquo; The API is friendly, but as of this post&rsquo;s publishing, <code>duckdb-rs</code> <a href="https://github.com/wangfenjin/duckdb-rs/issues/81">doesn&rsquo;t support any nested data types</a>, which I needed (my query results include <a href="https://duckdb.org/docs/sql/data_types/list.html">lists</a>). I looked into submitting a PR to support nested data types but my inexperience with Rust quickly put that to a halt.</p>
<p>The library code also starts from a fork of <code>rusqlite</code> and I think that makes things a bit challenging. SQLite does not support nested data types as far as I can tell (and I don&rsquo;t see evidence of any such support in <code>rusqlite</code>). Reading through the <code>duckdb-rs</code> source, it seemed like one would need to shoehorn first class support for nested data types into the library or redesign a chunk of the library&rsquo;s core.</p>
<p>I, uh, am not comfortable enough with Rust to attempt that in any reasonable amount of time.</p>
<p>Thankfully, the author of <code>duckdb-rs</code> also makes <a href="https://crates.io/crates/libduckdb-sys">libduckdb_sys</a> available: Rust bindings to <a href="https://duckdb.org/docs/api/c/api">DuckDB&rsquo;s C API</a>. And there&rsquo;s a <a href="https://github.com/wangfenjin/duckdb-rs/blob/main/libduckdb-sys/src/lib.rs#L51">bunch of example code</a> for how to use it in the <code>duckdb-rs</code> source.</p>
<p>This involves running a bunch of unsafe functions and dereferencing raw pointers, so you need to wrap a lot of this code in an <code>unsafe</code> block. Reading results from DuckDB&rsquo;s Arrow interface involves two steps.</p>
<p>First, execute the query using the Arrow interface:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#e78284">let</span> <span style="color:#ca9ee6">mut</span> result: <span style="color:#e5c890">duckdb_arrow</span> <span style="color:#99d1db;font-weight:bold">=</span> ptr::null_mut();
</span></span><span style="display:flex;"><span><span style="color:#e78284">let</span> state <span style="color:#99d1db;font-weight:bold">=</span> duckdb_query_arrow(conn, sql.as_ptr(), <span style="color:#99d1db;font-weight:bold">&amp;</span><span style="color:#ca9ee6">mut</span> result);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">// An example of error handling with this API. I&#39;ll skip this everywhere else.
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span><span style="color:#ca9ee6">if</span> state <span style="color:#99d1db;font-weight:bold">==</span> duckdb_state_DuckDBError {
</span></span><span style="display:flex;"><span>    <span style="color:#e78284">let</span> error_message: <span style="color:#99d1db;font-weight:bold">*</span><span style="color:#ca9ee6">const</span> c_char <span style="color:#99d1db;font-weight:bold">=</span> duckdb_query_arrow_error(result);
</span></span><span style="display:flex;"><span>    <span style="color:#e78284">let</span> error_message <span style="color:#99d1db;font-weight:bold">=</span> CStr::from_ptr(error_message).to_str().unwrap();
</span></span><span style="display:flex;"><span>    <span style="color:#8caaee">panic!</span>(<span style="color:#a6d189">&#34;</span><span style="color:#a6d189">{}</span><span style="color:#a6d189">&#34;</span>, error_message);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>&hellip; then fetch batches of results:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#e78284">let</span> <span style="color:#ca9ee6">mut</span> ffi_arrow_array: <span style="color:#e5c890">arrow2</span>::ffi::ArrowArray <span style="color:#99d1db;font-weight:bold">=</span> arrow2::ffi::ArrowArray::empty();
</span></span><span style="display:flex;"><span><span style="color:#e78284">let</span> state <span style="color:#99d1db;font-weight:bold">=</span> duckdb_query_arrow_array(
</span></span><span style="display:flex;"><span>    result,
</span></span><span style="display:flex;"><span>    <span style="color:#99d1db;font-weight:bold">&amp;</span><span style="color:#ca9ee6">mut</span> <span style="color:#99d1db;font-weight:bold">&amp;</span><span style="color:#ca9ee6">mut</span> ffi_arrow_array <span style="color:#ca9ee6">as</span> <span style="color:#99d1db;font-weight:bold">*</span><span style="color:#ca9ee6">mut</span> _ <span style="color:#ca9ee6">as</span> <span style="color:#99d1db;font-weight:bold">*</span><span style="color:#ca9ee6">mut</span> <span style="color:#99d1db;font-weight:bold">*</span><span style="color:#ca9ee6">mut</span> c_void, <span style="color:#737994;font-style:italic">// I don&#39;t get this. I got it from duckdb-rs.
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span>);
</span></span></code></pre></div><p>I do not understand why <code>ffi_arrow_array</code> needs to be cast this way. I do understand:</p>
<ul>
<li>The <a href="https://duckdb.org/docs/api/c/api#duckdb_query_arrow_array">C API</a> requires a <code>duckdb_arrow_array *</code> for that second argument.</li>
<li><code>duckdb_arrow_arrow</code> is an alias for <code>void *</code> (this is in the <a href="https://github.com/duckdb/duckdb/blob/master/src/include/duckdb.h">DuckDB C API headers</a>).</li>
<li>The argument it expects, then, is a <code>void **</code>.</li>
</ul>
<p>But the cast in Rust is baffling to me. What is <code>as *mut _</code> doing in the middle of this? What does this do and why is it necessary? If you know, <a href="https://twitter.com/voberoi">please tell me</a>.</p>
<p>On another note, piecing all this together is painful. I had to read <a href="https://duckdb.org/docs/api/c/api#duckdb_query_arrow_array">DuckDB&rsquo;s C API</a> docs, <a href="https://github.com/duckdb/duckdb/blob/master/src/include/duckdb.h">DuckDB&rsquo;s C header</a>, and the <a href="https://docs.rs/arrow2/latest/arrow2/">arrow2 crate docs</a> to figure out why this particular incantation works. I am not sure there is a good solution to this. These are disparate, relatively immature projects. Maybe it just takes time? (And blog posts like these?)</p>
<p><strong>Turning an <code>arrow2::ffi::ArrowArray</code> into a Polars DataFrame</strong></p>
<p>With the code above we&rsquo;ve got a batch of results from DuckDB populated <a href="https://arrow.apache.org/docs/format/CDataInterface.html#structure-definitions">in these Arrow C structs</a>. But we&rsquo;d like to go from these to a Polars DataFrame. How?</p>
<p>I used the <code>arrow2</code> crate instead of <code>arrow</code> because it looks like Polars uses <code>arrow2</code> and figured I might have an easier on-ramp to creating a Polars DataFrame.</p>
<p>We need to do the following:</p>
<ul>
<li>Create a Rust-native Arrow array so we stop working with these C structs.</li>
<li>Create a Polars Series for each column in our result set from the Arrow array.</li>
<li>Create a Polars DataFrame from those Series.</li>
</ul>
<p>To start with, we also need to get the Arrow array&rsquo;s schema. DuckDB provides an API for this too:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#e78284">let</span> <span style="color:#ca9ee6">mut</span> schema <span style="color:#99d1db;font-weight:bold">=</span> arrow2::ffi::ArrowSchema::empty();
</span></span><span style="display:flex;"><span><span style="color:#e78284">let</span> state <span style="color:#99d1db;font-weight:bold">=</span>
</span></span><span style="display:flex;"><span>    duckdb_query_arrow_schema(result, <span style="color:#99d1db;font-weight:bold">&amp;</span><span style="color:#ca9ee6">mut</span> <span style="color:#99d1db;font-weight:bold">&amp;</span><span style="color:#ca9ee6">mut</span> schema <span style="color:#ca9ee6">as</span> <span style="color:#99d1db;font-weight:bold">*</span><span style="color:#ca9ee6">mut</span> _ <span style="color:#ca9ee6">as</span> <span style="color:#99d1db;font-weight:bold">*</span><span style="color:#ca9ee6">mut</span> <span style="color:#99d1db;font-weight:bold">*</span><span style="color:#ca9ee6">mut</span> c_void);
</span></span></code></pre></div><p>(There&rsquo;s that baffling cast again.)</p>
<p>Now we&rsquo;re going to exit C API-land and convert these raw C structs into a Rust-native <code>arrow2::array::Array</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#e78284">let</span> field <span style="color:#99d1db;font-weight:bold">=</span> arrow2::ffi::import_field_from_c(schema).unwrap();
</span></span><span style="display:flex;"><span><span style="color:#e78284">let</span> arrow_array <span style="color:#99d1db;font-weight:bold">=</span>
</span></span><span style="display:flex;"><span>    arrow2::ffi::import_array_from_c(ffi_arrow_array, field.data_type).expect(<span style="color:#a6d189">&#34;ok&#34;</span>);
</span></span></code></pre></div><p><code>arrow_array</code> is an <code>arrow2::array::Array</code>, which is a trait object that can be downcast to the specific Arrow array type you want. I don&rsquo;t know what a Rust trait object is yet, but I do know I want to iterate over an array of integers or whatever my query actually returns.</p>
<p>Let&rsquo;s assume this is our query:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ca9ee6">CREATE</span> <span style="color:#ca9ee6">TABLE</span> users (
</span></span><span style="display:flex;"><span>    id <span style="color:#99d1db">INTEGER</span>,
</span></span><span style="display:flex;"><span>    username <span style="color:#99d1db">VARCHAR</span>
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">-- Insert a bunch of data --
</span></span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic"></span>
</span></span><span style="display:flex;"><span><span style="color:#ca9ee6">SELECT</span> id, username <span style="color:#ca9ee6">FROM</span> users;
</span></span></code></pre></div><p>It turns out that the <code>arrow_array</code> DuckDB&rsquo;s Arrow interface returns is a <a href="https://docs.rs/arrow2/latest/arrow2/array/struct.StructArray.html"><code>StructArray</code></a>, which is just a struct that contains multiple Arrow arrays of the same length. For our query above, it&rsquo;ll be a <code>StructArray</code> containing two arrays: one for <code>id</code> and one for <code>username</code>.</p>
<p>So we first downcast to a <code>StructArray</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#e78284">let</span> struct_array <span style="color:#99d1db;font-weight:bold">=</span> arrow_array
</span></span><span style="display:flex;"><span>    .as_any()
</span></span><span style="display:flex;"><span>    .downcast_ref::<span style="color:#99d1db;font-weight:bold">&lt;</span>StructArray<span style="color:#99d1db;font-weight:bold">&gt;</span>()
</span></span><span style="display:flex;"><span>    .expect(<span style="color:#a6d189">&#34;This Arrow Array should be a StructArray.&#34;</span>);
</span></span></code></pre></div><p>Then we downcast each array in <code>struct_array</code> to their appropriate types. This was another area that took a while to grok. What are the types DuckDB returns in the <code>struct_array</code>? Are those user IDs signed or unsigned? Are they 32-bit or 64-bit?</p>
<p>Print out <code>struct_array.fields()</code> to view the data types you&rsquo;re getting back. If you wanted to construct a DataFrame dynamically from any query, you&rsquo;d have to inspect these fields then downcast each array to the corresponding type.</p>
<p>Here&rsquo;s what the code looks like to fetch and downcast to the specific Arrow array types we need for the query above:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#e78284">let</span> id_array <span style="color:#99d1db;font-weight:bold">=</span> struct_array.values()[<span style="color:#ef9f76">0</span>]
</span></span><span style="display:flex;"><span>    .as_any()
</span></span><span style="display:flex;"><span>    .downcast_ref::<span style="color:#99d1db;font-weight:bold">&lt;</span>Int32Array<span style="color:#99d1db;font-weight:bold">&gt;</span>()
</span></span><span style="display:flex;"><span>    .unwrap();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#e78284">let</span> username_array <span style="color:#99d1db;font-weight:bold">=</span> struct_array.values()[<span style="color:#ef9f76">1</span>]
</span></span><span style="display:flex;"><span>    .as_any()
</span></span><span style="display:flex;"><span>    .downcast_ref::<span style="color:#99d1db;font-weight:bold">&lt;</span>Utf8Array<span style="color:#99d1db;font-weight:bold">&lt;</span><span style="color:#e78284">i32</span><span style="color:#99d1db;font-weight:bold">&gt;&gt;</span>()
</span></span><span style="display:flex;"><span>    .unwrap();
</span></span></code></pre></div><p><code>Int32Array</code> is an alias provided by <code>arrow2</code> for a <code>PrimitiveArray</code> containing <code>i32</code>-type data. All primitive types are stored in Arrow arrays of types <code>PrimitiveArray</code>.</p>
<p><code>Utf8Array</code> is not an alias. And I think the generic argument to <code>Utf8Array</code> is the type used for Arrow offsets. Specifically, these are the offsets used to identify where a UTF-8 string at a given index might start. If you have the array [&ldquo;hello&rdquo;, &ldquo;world&rdquo;], the offset for &ldquo;hello&rdquo; is <code>0</code> and the offset of &ldquo;world&rdquo; is <code>5</code>.</p>
<p>(If that is incorrect, <a href="https://twitter.com/voberoi">holler</a>.)</p>
<p>A good place to start to read more about these type is the docs for <a href="https://docs.rs/arrow2/0.1.0/arrow2/array/index.html"><code>arrow2::array</code></a>.</p>
<p>Finally, we want to create Series from these arrays:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#e78284">let</span> id_series <span style="color:#99d1db;font-weight:bold">=</span> Series::try_from((<span style="color:#a6d189">&#34;id&#34;</span>, id_array.to_boxed())).unwrap();
</span></span><span style="display:flex;"><span><span style="color:#e78284">let</span> username_series <span style="color:#99d1db;font-weight:bold">=</span>
</span></span><span style="display:flex;"><span>    Series::try_from((<span style="color:#a6d189">&#34;username&#34;</span>, username_array.to_boxed())).unwrap();
</span></span></code></pre></div><p><code>to_boxed()</code> turns the downcasted arrays into a boxed <code>arrow2::array::Array</code>, the trait object all Arrow arrays are represented as in <code>arrow2</code>. This what Polars <code>Series</code> expects.</p>
<p>Finally, our dataframe:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-rust" data-lang="rust"><span style="display:flex;"><span><span style="color:#e78284">let</span> df <span style="color:#99d1db;font-weight:bold">=</span> DataFrame::new(<span style="color:#8caaee">vec!</span>[id_series, username_series]).unwrap();
</span></span></code></pre></div><p>&hellip; and that&rsquo;s how you start with a DuckDB query and end up with a Polars dataframe in Rust.</p>
<p><strong>Further reading</strong></p>
<ul>
<li>I&rsquo;ve put together a full example of the above in <a href="https://github.com/voberoi/duckdb-polars-rust">on Github</a>. It uses a Parquet file from the <a href="https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page">NYC TLC dataset</a>.</li>
<li><a href="https://duckdb.org/docs/api/c/api">DuckDB C API</a></li>
<li><a href="https://github.com/duckdb/duckdb/blob/master/src/include/duckdb.h">duckdb.h</a></li>
<li><a href="https://docs.rs/arrow2/latest/arrow2/">Arrow2</a></li>
<li><a href="https://duckdb.org/2021/12/03/duck-arrow.html">This post on DuckDB&rsquo;s Arrow integration is interesting.</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Compilers with David Beazley: a recursive descent into madness (and delight)</title>
      <link>https://vikramoberoi.com/posts/compilers-with-david-beazley-a-recursive-descent-into-madness-and-delight/</link>
      <pubDate>Sat, 19 Nov 2022 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/compilers-with-david-beazley-a-recursive-descent-into-madness-and-delight/</guid>
      <description>A cat taking a compilers course. Credit: katiebcartoons.com. I got carried away with that title, sorry.&#xA;I wrapped up David Beazley&amp;rsquo;s week-long compilers course yesterday and it was a total hoot. If you&amp;rsquo;ve been considering taking this course, I want to share my (lovely) experience and what you can expect from it.&#xA;David&amp;rsquo;s compilers class is an intense, hands-on primer to implementing a programming language. Over five days, we built an interpreter and compiler for a toy language called Wabbit from scratch.</description>
      <content:encoded><![CDATA[<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/compiler-cat-color.png"
        alt="A cat typing on a laptop taking a compilers course."
        title="A cat taking a compilers course. Credit: katiebcartoons.com."
    />
    <figcaption class="text-center">A cat taking a compilers course. Credit: <a href="https://katiebcartoons.com">katiebcartoons.com</a>.</figcaption>
</figure>

</p>
<p>I got carried away with that title, sorry.</p>
<p>I wrapped up <a href="https://dabeaz.com">David Beazley</a>&rsquo;s week-long <a href="https://dabeaz.com/compiler.html">compilers course</a> yesterday and it was a total hoot. If you&rsquo;ve been considering taking this course, I want to share my (lovely) experience and what you can expect from it.</p>
<p>David&rsquo;s compilers class is an intense, hands-on primer to implementing a programming language. Over five days, we built an interpreter and compiler for a toy language called Wabbit from scratch. David designed the language and the spec is small enough to get something cool working while having <em>gobs</em> to wrap up and explore if you choose to.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/Screen-Shot-2022-11-19-at-10.08.27-AM.png"
        alt="The Mandelbrot set printed out on a terminal window."
        title="It was thrilling to be able to compile and run Wabbit code that prints out the Mandelbrot set!"
    />
    <figcaption class="text-center">It was thrilling to be able to compile and run Wabbit code that prints out the Mandelbrot set!</figcaption>
</figure>

</p>
<p>David&rsquo;s done an outstanding job designing the project for this class. It was challenging but I learned a ton and always had something to show for my effort. Every day we&rsquo;d have working code for some practical aspect of Wabbit that also set us up the next day. It was a <em>great</em> way to build momentum.</p>
<p>For example, on day one I built a syntax tree model and a code formatter that could take a tree I constructed and generate valid, nicely-formatted Wabbit code. This might look something like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#c6d0f5;background-color:#303446;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#737994;font-style:italic"># The following returns &#34;print 4 + 5;&#34;</span>
</span></span><span style="display:flex;"><span><span style="color:#737994;font-style:italic">#</span>
</span></span><span style="display:flex;"><span><span style="color:#99d1db">format</span>(
</span></span><span style="display:flex;"><span>    Statements(
</span></span><span style="display:flex;"><span>        PrintStatement(
</span></span><span style="display:flex;"><span>            Add(IntegerLiteral(<span style="color:#a6d189">&#34;4&#34;</span>), IntegerLiteral(<span style="color:#a6d189">&#34;5&#34;</span>))
</span></span><span style="display:flex;"><span>        )
</span></span><span style="display:flex;"><span>    )
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>The example above is trivial but the model I built on the first day covered the entire language spec. It gave me a solid foundation to build a parser the next day. I also had a practical tool for Wabbit and got to appreciate how code formatters I use every day are built. <a href="https://github.com/psf/black/blob/main/src/black/linegen.py">Python Black&rsquo;s line generation code</a> is not far from what we implemented on day one.</p>
<p>I think that is an awesome result to have so quickly, and it&rsquo;s what you can expect daily.</p>
<p>I won&rsquo;t go into more detail here because David experiments with his course and spends a ton of time on its design and his materials.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/Screen-Shot-2022-11-19-at-10.37.28-AM.png"
        alt="A printout of error messages on a terminal screen."
        title="Emitting useful error messages was easily the most difficult part of the course for me. It is really tough to do this well and where I made the least progress."
    />
    <figcaption class="text-center">Emitting useful error messages was easily the most difficult part of the course for me. It is really tough to do this well and where I made the least progress.</figcaption>
</figure>

</p>
<p>If you&rsquo;re going to take the course, note that it is a ~40 hour commitment. There are about 3 hours of discussion daily with the rest of the time dedicated to hands-on implementation. There&rsquo;s a Gitter chat where David and all the students can ask questions and share code and confusion. Everyone&rsquo;s working on their implementation in the open, David included, so you can pop into different branches and see approaches folks are taking.</p>
<p>The course is light on theory. I took a compilers course in college about 15 years ago and I think we spent ~2-3 weeks investigating different parsing techniques and talking about automata and grammars. David does not do that.</p>
<p>Yet I have a solid foundation for further study after this week. I know how topics like parsing, type checking, semantic analysis, garbage collection, and code optimization fit in the language implementation landscape. I understand the contours of those topics, what to search for, and how I might go about navigating code to explore them in open source projects. I <em>think</em> I know where I would need to begin with any of those in my Wabbit implementation.</p>
<p>One neat thing I want to note is that we talked about error message generation more than I expected. David&rsquo;s keen on figuring out how to do this well. He mentioned that compiler books all punt on the topic of error messages, but he doesn&rsquo;t because they&rsquo;re core to making a programming language usable.</p>
<p>Importantly, it&rsquo;s representative of where his course falls on the spectrum between theory and practice.</p>
<p>David&rsquo;s a great teacher. If you haven&rsquo;t seen any of his talks on YouTube yet, seek them out. His compilers course is a fantastic way to survey the field as a practitioner. You&rsquo;ll write a lot of code and build an interpreter <em>and</em> a compiler in five days. The work will challenge you but you&rsquo;ll learn a lot and be rewarded with some exciting results.</p>
<p>If that sounds like your cup of tea, take the course. Sign up for compilers, or another one of David&rsquo;s courses, here: <a href="https://dabeaz.com/">https://dabeaz.com/</a>.</p>
<p><strong>Quick tips for folks about to take the course</strong></p>
<p>If you&rsquo;re about to take the course, here are some quick tips for you:</p>
<ul>
<li><strong>Definitely use a program language you are comfortable in.</strong> I do not recommend using this as an opportunity learn a new language. Make no mistake: you will write a <em>lot</em> of code and you don&rsquo;t want other details to distract you from the material at hand.</li>
<li><strong>In a similar vein, this isn&rsquo;t a good time to mess with your editor/environment.</strong> You might be tempted to try out a new editor or something. I don&rsquo;t recommend doing that either. It&rsquo;s distracting.</li>
<li><strong>Poke around everyone&rsquo;s branches.</strong> It is super interesting to see how other folks approach the same problem. You might learn something new.</li>
<li><strong>Get good sleep, eat well, etc.</strong> I guess this should apply to&hellip; every day life. But the pace of David&rsquo;s course is intense and my brain was fried every day. I couldn&rsquo;t have kept pace if I wasn&rsquo;t feeling good.</li>
<li><strong>Have fun!</strong> His class will challenge and confuse you. You will make a lot of mistakes and introduce a lot of bugs. Your code will probably be a mess. Don&rsquo;t worry about it, roll with it, and enjoy the process! It&rsquo;s fun to hack on something new.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>How I made atariemailarchive.org</title>
      <link>https://vikramoberoi.com/posts/how-i-made-atariemailarchive-org/</link>
      <pubDate>Fri, 09 Sep 2022 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/how-i-made-atariemailarchive-org/</guid>
      <description>An artist&amp;rsquo;s rendition of the author reading Jed Margolin&amp;rsquo;s emails on the NYC subway. Credit: katiebcartoons.com 🥳&#xA;I&amp;rsquo;ve published the data behind atariemailarchive.org under a Creative Commons license. You can find it on Github. 🎙️&#xA;Update (March 29, 2023): You can listen to me talk about atariemailarchive.org on episode 5 of Data is Plural&amp;rsquo;s inaugural podcast season. In 2015 I discovered Jed Margolin&amp;rsquo;s emails from his tenure as a hardware engineer at Atari: 4,128 messages he sent and received between 1983 and 1992.</description>
      <content:encoded><![CDATA[<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/dc4fa6867e1a75a758ea4af58bffd498371617dd.jpg"
        alt="A cartoon of a man standing in crowded subway train looking at his phone."
        title="An artist&rsquo;s rendition of the author reading Jed Margolin&rsquo;s emails on the NYC subway. Credit: katiebcartoons.com"
    />
    <figcaption class="text-center">An artist&rsquo;s rendition of the author reading Jed Margolin&rsquo;s emails on the NYC subway. Credit: <a href="https://katiebcartoons.com">katiebcartoons.com</a></figcaption>
</figure>

</p>
<div class="bg-featured callout p-6 flex items-start gap-5 my-8">
    
    <p class="text-xl">🥳</p>
    
    <div class="my-0">I&rsquo;ve published the data behind <a href="https://atariemailarchive.org">atariemailarchive.org</a> under a Creative Commons license. <a href="https://github.com/voberoi/atariemailarchive-data">You can find it on Github.</a></div>
</div>

<div class="bg-featured callout p-6 flex items-start gap-5 my-8">
    
    <p class="text-xl">🎙️</p>
    
    <div class="my-0"><strong>Update (March 29, 2023):</strong> You can listen to me talk about atariemailarchive.org on <a href="https://podcast.data-is-plural.com/2159594/12535597-s1e5-atari-emails">episode 5 of Data is Plural&rsquo;s inaugural podcast season.</a></div>
</div>

<p>In 2015 I discovered <a href="https://www.jmargolin.com/vmail/vmail.htm">Jed Margolin&rsquo;s emails from his tenure as a hardware engineer at Atari</a>: 4,128 messages he sent and received between 1983 and 1992.</p>
<p>&hellip; and then I read them all during my subway commute to work for the next several months.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/blogimage.png"
        alt="A screenshot of an email from Jed&amp;rsquo;s website."
        title="This is what it looks like reading Jed&rsquo;s emails from his website on an iPhone. Not pictured: being jostled around on a crowded train commute while reading it."
    />
    <figcaption class="text-center">This is what it looks like reading Jed&rsquo;s emails from <a href="http://www.jmargolin.com/vmail/vmail.htm">his website</a> on an iPhone. <strong>Not pictured:</strong> being jostled around on a crowded train commute while reading it.</figcaption>
</figure>

</p>
<p>Not all four thousand messages in Jed&rsquo;s inbox are captivating. I&rsquo;ll admit I glossed over a lot of the 1989 <a href="https://atariemailarchive.org/thread/stun-runner-status-report-936">project status reports</a> for <a href="https://en.wikipedia.org/wiki/S.T.U.N._Runner">Stun Runner</a>. But they were collectively fascinating. I kept finding <a href="https://atariemailarchive.org/top-threads">gems</a>.</p>
<p>I loved all the day-to-day mundanity too: coworkers throwing wee jokes into their status reports, minutes from engineering meetings, Jed&rsquo;s constant displeasure with Bob Frye from facilities. (Why are there always ants in the cafeteria, Bob?!)</p>
<p>Technology&rsquo;s changed since the 80&rsquo;s and that came across loudly when I read Jed&rsquo;s emails. But all the human interaction and how engineers built technology and did creative work felt the same. Reading Jed&rsquo;s emails was relatable and comforting.</p>
<p>In 2017, two years after I discovered Jed&rsquo;s emails, I quit my job and took some time off. That spring I contacted Jed for permission to work on what would later become <a href="https://atariemailarchive.org">atariemailarchive.org</a>.</p>
<blockquote>
<p>Hi, Vikram.</p>
<p>Yes, I am ok with it (as long as you do not use it for any nefarious<br>
purposes). :-)</p>
<p>I don&rsquo;t think there is any legal stuff to do.</p>
<p>And thank you. I don&rsquo;t want this stuff to disappear when I am gone. (I am<br>
old and decrepit.)</p>
<p>Regards,</p>
<p>Jed</p>
</blockquote>
<p>Confident that I met Jed&rsquo;s stringent criteria not to weaponize his publicly-available professional emails from the 80&rsquo;s, I began work.</p>
<p>I contacted Jed on May 15th and released <a href="https://atariemailarchive.org">atariemailarchive.org</a> on December 15th. I worked on the project on and off over seven months.</p>
<p><a href="https://atariemailarchive.org">atariemailarchive.org</a> allows folks to enjoy Jed&rsquo;s emails without obsessively reading <em>everything</em> like I did. It gives readers threaded conversations, curation with a <a href="https://atariemailarchive.org/top-threads">&ldquo;best of&rdquo; list</a>, and an easier-to-read interface than <a href="https://www.jmargolin.com/vmail/Vax83.txt">a giant text file</a>.</p>
<p>But that&rsquo;s not how it started. I built a version and wrote a bunch of code that I scrapped before I built what you see on <a href="https://atariemailarchive.org">atariemailarchive.org</a> today.</p>
<h1 id="the-version-that-never-saw-the-light-of-day">The version that never saw the light of day</h1>
<p>My goal with <a href="https://atariemailarchive.org/">atariemailarchive.org</a> is simple: I want it to be able to hook you during your lunch hour.</p>
<p>As a proxy to test if you might get hooked, I just want any indication that you are consumed by <a href="https://atariemailarchive.org">atariemailarchive.org</a> when I tell you about it. If you tap around, chuckle a lot, and start ignoring me, that is sufficient.</p>
<p>By this rigorous and scientific measure, the first version I built failed spectacularly. Readers got bored quickly.</p>
<p>I had initially built a search interface and an easier way to read and navigate all of Jed&rsquo;s individual messages. When I showed it to folks, they tapped around, searched for <code>jobs</code> and <code>wozniak</code> (neither appears in the archive), and then asked me what they should look for.</p>
<p>If you aspire to curate a someone&rsquo;s public emails some day, here&rsquo;s a tip: search is an awful way to curate text when your readers don&rsquo;t know what is interesting about it. This is obvious in retrospect, but it didn&rsquo;t occur to me until I showed what I built to folks.</p>
<p>For example, a lot of folks have created neat search interfaces for the Enron emails. I <em>know</em> there are fascinating emails in that archive, yet I&rsquo;ve barely read read any of them. I don&rsquo;t know where to look.</p>
<p>I realize it is significantly harder given that the Enron archive contains 500,000 messages, but I hope someone creates something like <a href="https://atariemailarchive.org">atariemailarchive.org</a> for the Enron emails some day, somehow.</p>
<p>I know I&rsquo;d spend my lunch hour reading it.</p>
<h1 id="the-version-that-_did_-see-the-light-of-day">The version that <em>did</em> see the light of day</h1>
<p>I realized that if I wanted folks to see what I saw in the archive, I&rsquo;d need to guide them directly to it. <a href="https://atariemailarchive.org">atariemailarchive.org</a> would need resemble the emails I sent to friends and coworkers when I wanted to share fun messages I found in Jed&rsquo;s inbox.</p>
<p>That&rsquo;s what led me to do three things:</p>
<ol>
<li>I created conversation threads from every message in the archive.</li>
<li>I assembled a <a href="https://atariemailarchive.org/top-threads">&ldquo;best of&rdquo; list</a> with some commentary.</li>
<li>I <a href="https://atariemailarchive.org/categories">tagged threads</a>.</li>
</ol>
<p>I actually tried #2 and #3 on Jed&rsquo;s individual messages first to test it out. It worked. Folks tapped around and chuckled a lot. They told me they wanted more.</p>
<p>But when I tried to tag more messages and add them to the &ldquo;best of&rdquo; list, I quickly realized how many messages existed in a larger thread, and how important that context was for all the little discoveries I made to truly <em>hit</em>. You can see this easily if you click around on <a href="https://atariemailarchive.org">atariemailarchive.org</a> today.</p>
<p>Threads did not exist in the 80&rsquo;s. Jed&rsquo;s inbox was just a bunch of email ordered chronologically. So I bit the bullet and created conversation threads from <em>all 4,128 of Jed&rsquo;s messages.</em></p>
<p>As you can imagine, this was tedious. It was the most time consuming part of this project. I was living in Seattle at the time and I&rsquo;d like to thank the folks at <a href="http://www.porchlightcoffee.com/">Porchlight Coffee</a> for letting me hang out there over many, many afternoons while I manually threaded all of Jed&rsquo;s emails.</p>
<h1 id="whats-next-for-the-archive">What&rsquo;s next for the archive?</h1>
<p>I decided to publish the dataset I created for <a href="https://atariemailarchive.org">atariemailarchive.org</a> under a Creative Commons license today. You can find it <a href="https://github.com/voberoi/atariemailarchive-data">on Github</a>.</p>
<p>I would be thrilled to see people do interesting things with it. If you do, please let me know.</p>
<p>People use the archive in different ways. Occasionally readers will link to messages that help them in some investigation or research they&rsquo;re doing. <a href="https://twitter.com/PhilBennett3D/status/1395432015214759937?s=20&amp;t=OywCQlWoDaztgPOCX4C2hA">Here&rsquo;s one of my favorites.</a> Jed received a price list for custom parts made for Atari hardware, and the person who authored this tweet found them in a breakdown of an arcade cabinet. You can see the part ids match in the image and the email.</p>
<p>It has been a joy to see folks link to and enjoy the archive and I hope to see more of that. I get messages of appreciation every now and then. I love receiving those. It validates all the time I spent on this bizarre obsession.</p>
<h1 id="technical-notes-for-those-interested">Technical notes for those interested</h1>
<p><a href="https://atariemailarchive.org">atariemailarchive.org</a> began its life as a Python <a href="https://trypyramid.com/">Pyramid</a> app with a SQLite back-end in 2017. It ran on a $5/month Linode instance until early September 2022.</p>
<p>This week I ported it Python Flask and deployed it to <a href="https://fly.io">Fly.io</a>. The app is trivial and porting it to Python Flask took two afternoons. Deploying <a href="https://atariemailarchive.org">atariemailarchive.org</a> to Fly.io took very little time – Fly.io was a joy to use and it will cost me nothing to host the archive there.</p>
<p>The <a href="https://github.com/voberoi/atariemailarchive-data">dataset on Github</a> is in a SQLite file. It contains structured, threaded messages that I parsed and assembled from text files on Jed&rsquo;s site. It does not contain any of the curation and editorialization on <a href="https://atariemailarchive.org">atariemailarchive.org</a>.</p>
<p><em>Thanks to Katie Brookoff.</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>A primer on Roaring bitmaps: what they are and how they work</title>
      <link>https://vikramoberoi.com/posts/a-primer-on-roaring-bitmaps-what-they-are-and-how-they-work/</link>
      <pubDate>Fri, 02 Sep 2022 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/a-primer-on-roaring-bitmaps-what-they-are-and-how-they-work/</guid>
      <description>Unfortunately not what this post is about. Credit: katiebcartoons.com I came across Roaring bitmaps when I learned about this fun hack to do retention analyses at scale with bitmaps. Using Roaring bitmaps instead of traditional bitmaps in that application reduced memory usage from ~125GB to 300MB, an impressive 99.8% savings.&#xA;But&amp;hellip; how?&#xA;You can learn about Roaring bitmaps over two research papers:&#xA;This one proposes the data structure. This one introduces a critical optimization.</description>
      <content:encoded><![CDATA[<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/24af07058354b122975355ca6527a5cdc2f7a1eb.png"
        alt="A cartoon of a lion roaring a string of 0s and 1s."
        title="Unfortunately not what this post is about. Credit: katiebcartoons.com"
    />
    <figcaption class="text-center">Unfortunately not what this post is about. Credit: <a href="https://katiebcartoons.com">katiebcartoons.com</a></figcaption>
</figure>

</p>
<p>I came across Roaring bitmaps when I learned about <a href="https://vikramoberoi.com/using-bitmaps-to-run-interactive-retention-analyses-over-billions-of-events-for-less-than-100-mo/">this fun hack to do retention analyses at scale with bitmaps</a>. Using Roaring bitmaps instead of traditional bitmaps in that application reduced memory usage from ~125GB to 300MB, an impressive 99.8% savings.</p>
<p>But&hellip; how?</p>
<p>You can learn about Roaring bitmaps over two research papers:</p>
<ol>
<li><a href="https://arxiv.org/pdf/1402.6407.pdf">This one proposes the data structure.</a></li>
<li><a href="https://arxiv.org/pdf/1603.06549.pdf">This one introduces a critical optimization.</a></li>
</ol>
<p>In this post I briefly describe what bitmaps are, what they&rsquo;re used for, and what Roaring bitmaps solve that traditional bitmaps don&rsquo;t. Then, I distill the high-level structure of Roaring bitmaps and how they work, one step at a time.</p>
<p>Roaring bitmaps employ a number of algorithms, techniques, and heuristics that I won&rsquo;t go into in detail. They also offer some operations beyond the ones I describe. These details are not critical to understanding the basic internal structure and operation of Roaring bitmaps which is the focus of this post.</p>
<p>Let&rsquo;s begin!</p>
<h1 id="what-are-bitmaps-and-what-are-they-used-for">What are bitmaps and what are they used for?</h1>
<p><a href="https://en.wikipedia.org/wiki/Bit_array">Bitmaps</a> are arrays of bits used to store sets of integers.</p>
<p>They work by setting the Nth bit when an integer N is in the set, as illustrated in <strong>Figure 1.</strong></p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/bitmap-3.png"
        alt="An illustration of how a bitmap works."
        title="Figure 1. An illustration of how a bitmap works."
    />
    <figcaption class="text-center"><strong><strong>Figure 1.</strong></strong> An illustration of how a bitmap works.</figcaption>
</figure>

</p>
<p>By storing sets of integers this way, bitmaps can take advantage of extremely fast bitwise-AND and bitwise-OR CPU instructions to compute set intersections and unions.</p>
<p>It turns out that fast set intersections and unions are critical for many search and database applications. Various operations exist in search and database indexes that boil down to having two sets of integers and needing to intersect or union them quickly.</p>
<p>Take an inverted search index, for example:</p>
<ul>
<li><strong>You&rsquo;ve indexed billions of documents.</strong> Each document has an integer id.</li>
<li><strong>The index maps terms to a set of documents in which they appear.</strong> For example, the term <code>pigeon</code> appears in documents with these ids: <code>{2, 345, 2034, ...}</code>.</li>
<li><strong>Queries that search across terms use set operations.</strong> In order to resolve a search query like <code>carrier AND pigeon</code> you need the intersection of the set of documents that contain <code>carrier</code> and the set of documents that contain <code>pigeon</code>.</li>
<li><strong>Bitwise operations can perform these set operations quickly.</strong> If you represent sets of document ids as bitmaps, the query above is a bitwise-AND.</li>
</ul>
<p>Columnar databases use set operations similarly for certain classes of queries.</p>
<p>If you&rsquo;d like to dive into a use case outside search and databases, <a href="https://vikramoberoi.com/using-bitmaps-to-run-interactive-retention-analyses-over-billions-of-events-for-less-than-100-mo/">a previous post I wrote</a> discusses how bitmaps can be used to analyze user retention for SaaS products.</p>
<p>Unfortunately, bitmaps suffer from awful compression in common cases involving very large sets of integers – scenarios that appear frequently in the use cases I just described.</p>
<p>Recall the figure I cited at the beginning of the post:</p>
<blockquote>
<p>Using Roaring bitmaps instead of traditional bitmaps in that application reduced memory usage from ~125GB to 300MB, an impressive 99.8% savings.</p>
</blockquote>
<p><em>That&rsquo;s</em> the problem with bitmaps, and I&rsquo;ll walk you through why this happens shortly.</p>
<h1 id="what-are-roaring-bitmaps">What are Roaring bitmaps?</h1>
<p>From <a href="https://roaringbitmap.org">roaringbitmap.org</a>:</p>
<blockquote>
<p>Roaring bitmaps are compressed bitmaps. They can be hundreds of times faster.</p>
</blockquote>
<p>A great, pithy marketing statement on the Roaring bitmap website! Let&rsquo;s expand on it a wee bit.</p>
<p>Roaring bitmaps are just optimized bitmaps, <em>which I&rsquo;ll refer to henceforth as &ldquo;traditional bitmaps&rdquo;</em>.</p>
<p>Both traditional and Roaring bitmaps offer a set data structure for integers. You can insert integers, check the existence of an integer, and get the intersection and union of two sets of integers.</p>
<p>Roaring bitmaps offer better compression than traditional bitmaps. Importantly, they do so without significantly sacrificing the performance of set operations.</p>
<p><a href="https://roaringbitmap.org">roaringbitmap.org</a> boasts an impressive list of OLAP databases and search systems that use Roaring bitmaps under the hood. These are all applications that:</p>
<ul>
<li>&hellip; need to store large sets of integers</li>
<li>&hellip; in as little memory as possible</li>
<li>&hellip; and execute fast set operations.</li>
</ul>
<h1 id="what-problem-do-roaring-bitmaps-solve-that-traditional-bitmaps-dont">What problem do Roaring bitmaps solve that traditional bitmaps don&rsquo;t?</h1>
<p>When a set is <strong>sparse</strong>, traditional bitmaps compress poorly.</p>
<p>Recall that traditional bitmaps will set the Nth bit when you add an integer N to it (see <strong>Figure 1</strong>).</p>
<p>Let&rsquo;s say you have an empty traditional bitmap to which you add the integer 8,000,000. Here&rsquo;s what will happen:</p>
<ul>
<li>It will allocate 1,000,000 bytes.</li>
<li>It will set the 8,000,000th bit.</li>
</ul>
<p>This is illustrated in <strong>Figure 2</strong>.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/8mm-bit.png"
        alt="An illustration of what happens if you allocate the 8 millionth bit outright in an empty bitmap."
        title="Figure 2. What happens if you allocate the 8 millionth bit outright in an empty bitmap."
    />
    <figcaption class="text-center"><strong>Figure 2.</strong> What happens if you allocate the 8 millionth bit outright in an empty bitmap.</figcaption>
</figure>

</p>
<p>Why is this bad?</p>
<ul>
<li>Your set has 1 integer.</li>
<li>An integer takes up 4 bytes.</li>
<li>Your traditional bitmap has allocated 1 megabyte.</li>
</ul>
<p>That&rsquo;s 6 orders of magnitude more memory than you need.</p>
<p>Whoops!</p>
<p>Roaring bitmaps solve this problem. Importantly, they do so <strong>while maintaining fast set operations</strong>. This is what makes Roaring bitmaps special.</p>
<p>Prior research attempts to solve poor compression in bitmaps achieve impressive results too, but at the cost of efficient set operations.</p>
<h1 id="how-do-roaring-bitmaps-work">How do Roaring bitmaps work?</h1>
<p>There isn&rsquo;t one deep insight that allows Roaring bitmaps to perform well. But there is <em>a lot of stuff</em> going on that is greater than the sum of its parts.</p>
<p>The following builds up an understanding of Roaring bitmaps one concept at a time.</p>
<h2 id="part-1-how-roaring-bitmaps-are-represented-in-memory">Part 1: how Roaring bitmaps are represented in memory</h2>
<h3 id="all-32-bit-integers-are-partitioned-into-contiguous-chunks">All 32-bit integers are partitioned into contiguous chunks.</h3>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/chunks-1.png"
        alt="An illustration of how the space of 32-bit integers is partitioned into chunks in Roaring bitmaps."
        title="Figure 3. How the space of 32-bit integers is partitioned into chunks in Roaring bitmaps."
    />
    <figcaption class="text-center"><strong>Figure 3.</strong> How the space of 32-bit integers is partitioned into chunks in Roaring bitmaps.</figcaption>
</figure>

</p>
<p>Each chunk shares the same 16 most significant bits.</p>
<p>As shown in <strong>Figure 3,</strong> the partitioning scheme used by Roaring bitmaps ensures that an integer will always belong to the same chunk of 2^16, or 65,536 consecutive integers.</p>
<p><strong>Note:</strong> there are 64-bit implementations of Roaring bitmaps, but this post does not go into them. See <a href="https://github.com/RoaringBitmap/CRoaring">CRoaring</a>, this <a href="https://github.com/outcaste-io/sroar">native Go implementation</a>, and, uh, <a href="https://r-libre.teluq.ca/930/1/Roaring64bits.pdf">this paper written in French</a> (if you find a translation, please let me know).</p>
<h3 id="integers-in-the-same-chunk-are-stored-in-containers">Integers in the same chunk are stored in containers.</h3>
<p>Chunks are how integers are logically partitioned in Roaring bitmaps. All integers that belong to a chunk are physically (in memory) stored in the same <strong><em>container</em></strong>.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/containers-3.png"
        alt="An illustration of three contrived examples of containers from the first Roaring bitmap paper."
        title="Figure 4. Three contrived examples of containers from the first Roaring bitmap paper."
    />
    <figcaption class="text-center"><strong>Figure 4.</strong> Three contrived examples of containers from the first Roaring bitmap paper.</figcaption>
</figure>

</p>
<p><strong>Figure 4</strong> shows examples of three different containers for three different chunks.</p>
<p>A chunk will always only have one container in a Roaring bitmap, defined by the 16 most significant bits of all the integers in the chunk.</p>
<p>If your program inserted the first 1,000 multiples of 62 into a Roaring bitmap, then they would be end up in the left-most container in <strong>Figure 4.</strong> That container&rsquo;s cardinality would be 1,000.</p>
<p>If you later inserted the integer 63, it would end up in the same container. The container&rsquo;s cardinality would then be 1,001.</p>
<p>As you&rsquo;ll see next, the cardinality of a container determines how it will be represented in memory.</p>
<h3 id="sparse-containers-contain--4096-integers-these-are-stored-as-sorted-packed-arrays">Sparse containers contain &lt;= 4,096 integers. These are stored as sorted packed arrays.</h3>
<p>Two of the containers in in <strong>Figure 4</strong> are sparse (with cardinalities 1,000 and 100) so they will be stored as <strong><em>sorted packed arrays</em></strong> of 16-bit integers.</p>
<p>By <strong>packed</strong>, we mean that we will be packing 32-bit integers into 16-bit integers as shown in <strong>Figure 5</strong>.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/sparse-containers-2.png"
        alt="An illustration of two sparse Roaring bitmap containers from Figure 2 alongside examples of how they are stored in memory."
        title="Figure 5. Two sparse Roaring bitmap containers from Figure 2 alongside examples of how they are stored in memory."
    />
    <figcaption class="text-center"><strong>Figure 5.</strong> Two sparse Roaring bitmap containers from Figure 2 alongside examples of how they are stored in memory.</figcaption>
</figure>

</p>
<p>Packing integers is possible because each container can store at most 2^16 distinct integers. To get the original 32-bit integer in a sparse container, we have to unpack it by combining the 16-bit integer with its 16 most significant bits.</p>
<p>These arrays are dynamically allocated so the memory used by a sparse container grows as it accrues integers.</p>
<h3 id="dense-containers-contain--4096-integers-these-are-stored-as-bitmaps">Dense containers contain &gt; 4,096 integers. These are stored as bitmaps.</h3>
<p>One of the containers in <strong>Figure 4</strong> is dense (with cardinality 215) so it will be stored as a traditional bitmap.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/dense-containers-2.png"
        alt="An illustration of a dense Roaring bitmap container from Figure 2 alongside an example of how it is stored in memory."
        title="Figure 6. A dense Roaring bitmap container from Figure 2 alongside an example of how it is stored in memory."
    />
    <figcaption class="text-center"><strong>Figure 6.</strong> A dense Roaring bitmap container from Figure 2 alongside an example of how it is stored in memory.</figcaption>
</figure>

</p>
<p>Dense containers are bitmaps containing 2^16 bits (8 kilobytes), allocated outright. The Nth bit in the bitmap maps to the Nth integer in a chunk.</p>
<h3 id="a-first-level-index-points-to-all-containers-the-index-is-stored-as-a-sorted-array">A first-level index points to all containers. The index is stored as a sorted array.</h3>
<p>The first-level index stores the 16 most significant bits for each container in the Roaring bitmap along with a pointer to the corresponding container.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/index-1.png"
        alt="An illustration of the containers described in Figure 2, 3, and 4 with a first-level index pointing to them."
        title="Figure 7. The containers described in Figure 2, 3, and 4 with a first-level index pointing to them."
    />
    <figcaption class="text-center"><strong>Figure 7.</strong> The containers described in Figure 2, 3, and 4 with a first-level index pointing to them.</figcaption>
</figure>

</p>
<p>The index is stored as a sorted array and grows dynamically as new containers are added to the Roaring bitmap.</p>
<h2 id="part-2-how-set-operations-work-with-roaring-bitmaps">Part 2: how set operations work with Roaring bitmaps</h2>
<h3 id="integer-insertion-varies-by-container-type-and-may-cause-a-containers-type-to-change">Integer insertion varies by container type and may cause a container&rsquo;s type to change.</h3>
<p>To insert an integer, N, get N&rsquo;s 16 most significant bits (<code>N / 2^16</code>) and use it to find N&rsquo;s corresponding container in the Roaring bitmap.</p>
<p>Insertions in array and bitmap containers work differently:</p>
<ul>
<li><strong>Bitmap:</strong> set the bit at <code>N % 2^16</code>.</li>
<li><strong>Array:</strong> insert <code>N % 2^16</code> in its position in the sorted array.</li>
</ul>
<p><em>Insertions may change the container type.</em> If an array container has 4,096 integers, first convert it to a bitmap container. Then set the bit at <code>N % 2^16</code>.</p>
<p>If a container doesn&rsquo;t already exist then create a new array container, add it to the Roaring bitmap&rsquo;s first-level index, and add N to the array.</p>
<h3 id="checking-for-existence-varies-by-container-type">Checking for existence varies by container type.</h3>
<p>To check if an integer N exists, get N&rsquo;s 16 most significant bits (<code>N / 2^16</code>) and use it to find N&rsquo;s corresponding container in the Roaring bitmap.</p>
<p>If the container doesn&rsquo;t exist, then N is not in the Roaring bitmap.</p>
<p>Checking for existence in array and bitmap containers works differently:</p>
<ul>
<li><strong>Bitmap:</strong> check if the bit at <code>N % 2^16</code> is set.</li>
<li><strong>Array:</strong> use binary search to find <code>N % 2^16</code> in the sorted array.</li>
</ul>
<h3 id="intersect-matching-containers-to-intersect-two-roaring-bitmaps-algorithms-vary-by-container-types-and-container-types-may-change">Intersect matching containers to intersect two Roaring bitmaps. Algorithms vary by container type(s), and container types may change.</h3>
<p>To intersect Roaring bitmaps A and B, it is sufficient to intersect matching containers in A and B.</p>
<p>This is possible because of how integers are partitioned in Roaring bitmaps: matching containers in A and B store integers with the same 16 most significant bits (the same chunks).</p>
<p>Intersection algorithms vary by the types of the containers involved, as do the resulting container types:</p>
<ul>
<li><strong>Bitmap /</strong> <strong>Bitmap:</strong> Compute the bitwise AND of the two bitmaps. If the cardinality is &lt;= 4,096, store the result in an array container, otherwise store it in a bitmap container.</li>
<li><strong>Bitmap / Array:</strong> Iterate over the array, checking for the existence of each 16-bit integer in the bitmap. If the integer exists, add it to the resulting array container – note that intersections of bitmap and array container types will always create an array container.</li>
<li><strong>Array / Array:</strong> Intersections of two array containers always create a new array container. The algorithm used to compute the intersection varies by a <a href="https://arxiv.org/pdf/1402.6407.pdf">cardinality heuristic described at the bottom of page 5 here</a>. It will either be a simple merge (as used in merge sort) or a galloping intersection, <a href="https://dl.acm.org/doi/10.1145/1877766.1877767">described in this paper</a>.</li>
</ul>
<p>If there is a container in either Roaring bitmap without a corresponding container in the other, it will not exist in the result: the intersection of an empty set and any set is an empty set.</p>
<h3 id="union-matching-containers-to-produce-a-roaring-bitmap-union-algorithms-vary-by-container-types-and-container-types-may-change">Union matching containers to produce a Roaring bitmap union. Algorithms vary by container type(s), and container types may change.</h3>
<p>To union Roaring bitmaps A and B, union all matching containers in A and B.</p>
<p>Union algorithms vary by the container types involved, as do the resulting container types:</p>
<ul>
<li><strong>Bitmap / Bitmap:</strong> Compute the bitwise OR of the two bitmaps. Unions of two bitmap containers will always create another bitmap container.</li>
<li><strong>Bitmap / Array:</strong> Copy the bitmap and set corresponding bits for all the integers in the array container. Unions of a bitmap and array container will always create another bitmap container.</li>
<li><strong>Array / Array:</strong> If the sum of the cardinalities of the two array containers is &lt;= 4,096, the resulting container will be an array container. In this case, add all integers from both arrays to a new array container. Otherwise, optimistically assume the resulting container will be a bitmap: create a new bitmap container and set all corresponding bits for all integers in both arrays. If the cardinality of the resulting container is &lt;= 4,096, convert the bitmap container back into an array container.</li>
</ul>
<p>Finally, add all containers in A and B that do not have a matching container to the result. Remember: this is a union, so all integers in Roaring bitmaps A and B must be in the resulting set.</p>
<h2 id="part-3-how-a-third-and-final-container-type--the-run-container--optimizes-long-runs-of-consecutive-integers">Part 3: how a third and final container type – the &ldquo;run&rdquo; container – optimizes long runs of consecutive integers.</h2>
<p>Parts 1 and 2 of this post cover most of the internal structure and operation of Roaring bitmaps. This final part covers an important optimization described in <a href="https://arxiv.org/pdf/1603.06549.pdf">the second Roaring bitmap paper.</a></p>
<h3 id="run-containers-represent-runs-of-consecutive-integers-with-two-16-bit-integers-the-run-start-and-run-length">Run containers represent runs of consecutive integers with two 16-bit integers: the run start and run length.</h3>
<p>From page 3 of the <a href="https://arxiv.org/pdf/1603.06549.pdf">second Roaring bitmap paper</a>:</p>
<blockquote>
<p>The new container is conceptually simple: given a run (e.g., [10, 1000]), we store the starting point (10) and its length minus one (990) &hellip; packing the starting points and the lengths in pairs, using 16 bits each &hellip;</p>
</blockquote>
<p>This technique is known as <a href="https://en.wikipedia.org/wiki/Run-length_encoding">run-length encoding</a> and seems to back most of the prior art described in the two papers. Run-length encoding can compress bitmaps effectively but degrades performance for set operations in many cases.</p>
<h3 id="run-containers-are-formed-explicitly-when-a-client-invokes-a-_runoptimize_-function-or-in-some-cases-implicitly-when-a-large-range-is-added-to-the-roaring-bitmap">Run containers are formed explicitly when a client invokes a <em>runOptimize</em> function or, in some cases, implicitly when a large range is added to the Roaring bitmap.</h3>
<p>Unlike sparse and dense containers, run containers generally do not materialize automatically.</p>
<ol>
<li>Clients can invoke <em>runOptimize</em> to optimize their Roaring bitmap for large runs of consecutive integers. Run containers <strong><em>may</em></strong> replace existing array or bitmap containers in this case.</li>
<li>Roaring bitmaps offer an operation to add a range of values. Run containers <strong><em>may</em></strong> materialize automatically in this case.</li>
</ol>
<p>The papers don&rsquo;t actually prescribe how or when #2 should happen. I&rsquo;d guess that if a range of values were added for a chunk that does not yet have a container, it makes sense to create a run container instead of an array or bitmap container.</p>
<h3 id="_runoptimize_-only-creates-a-run-container-if-it-will-be-smaller-than-the-container-it-would-replace"><em>runOptimize</em> only creates a run container if it will be smaller than the container it would replace.</h3>
<p><em>runOptimize</em> first counts the number of runs in a container.</p>
<p>Then, it decides whether or not to create a run container using a simple heuristic: the run container must be smaller than its equivalent array or bitmap container.</p>
<p>The algorithms used to count runs and a description of how to compute the heuristic above are described on <a href="https://arxiv.org/pdf/1603.06549.pdf">page 6 and 7 in the second Roaring bitmap paper</a>.</p>
<p>If you&rsquo;d like to work out computing the heuristic yourself, it&rsquo;ll help to recall the following:</p>
<ul>
<li>&hellip; array containers contain no more than 4,096 integers, packed into 16-bits each.</li>
<li>&hellip; bitmap containers contain &gt; 4,096 integers in a bitmap with 2^16 bits (8,192 bytes).</li>
<li>&hellip; each run in a run container takes up 32 bits (16 bits for the start, 16 bits for the length).</li>
</ul>
<h3 id="the-addition-of-run-containers-introduces-new-algorithms-for-all-set-operations">The addition of run containers introduces new algorithms for all set operations.</h3>
<p>The Roaring bitmap papers do not describe the algorithms used to insert and check for the existence of integers in run containers: these operations are relatively straightforward.</p>
<p>But the addition of run containers requires that Roaring bitmaps implement performant algorithms for unions and intersections of <em>three</em> new container type pairs:</p>
<ul>
<li>Run / Run</li>
<li>Run / Array</li>
<li>Run / Bitmap</li>
</ul>
<p>These algorithms also introduce new heuristics to determine the resulting container type from these operations.</p>
<p>I won&rsquo;t go into the details of these algorithms as I did in Part 2. The algorithms are not significantly more complicated (their descriptions start <a href="https://arxiv.org/pdf/1603.06549.pdf">on page 10 here</a> if you&rsquo;re curious), but the numerous details are beyond the scope of this post and the paper is well-written and makes them extremely accessible.</p>
<h1 id="roaring-bitmaps-a-game-of-performance-whack-a-mole">Roaring bitmaps: a game of performance whack-a-mole!</h1>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/6roe7c.jpg"
        alt="The whack-a-mole meme applies to a Roaring bitmap creator, hard at work."
        title="A Roaring bitmap creator hard at work."
    />
    <figcaption class="text-center">A Roaring bitmap creator hard at work.</figcaption>
</figure>

</p>
<p>Roaring bitmaps use a kitchen sink of algorithms and techniques to achieve better compression and faster performance than other bitmap implementations.</p>
<p>They&rsquo;re challenging to implement but they do the job extremely well, especially when used in OLAP workloads. The creators managed to root out inefficiencies in common yet very different scenarios – sparse data, dense data, data with a lot of runs – and address all of them at once.</p>
<p>And they go even further!</p>
<p>A <a href="https://arxiv.org/pdf/1709.07821v4.pdf">third paper</a> describes an implementation the creators wrote in C that leverages vectorized algorithms they designed to use SIMD (single instruction multiple data) instructions. That implementation, CRoaring, along with bindings and implementations in multiple other languages are <a href="https://github.com/RoaringBitmap">available here</a>. They&rsquo;re used in mainstream columnar databases and search applications and are actively maintained, improved, and optimized regularly.</p>
<p>Very cool.</p>
<h1 id="further-reading">Further reading</h1>
<p>If you enjoyed reading this post and want to dig in further, I recommend reading the Roaring bitmap papers. They&rsquo;re well-written and accessible.</p>
<ul>
<li><a href="https://arxiv.org/pdf/1402.6407.pdf">Better bitmap performance with Roaring bitmaps</a></li>
<li><a href="https://arxiv.org/pdf/1603.06549.pdf">Consistently faster and smaller compressed bitmaps with Roaring</a></li>
<li><a href="https://arxiv.org/pdf/1709.07821v4.pdf">Roaring Bitmaps: Implementation of an Optimized Software Library</a></li>
</ul>
<p>Roaring bitmap implementations are <a href="https://github.com/RoaringBitmap">available on Github</a>.</p>
<p><em>Thanks to Chuck Groom, Andy O&rsquo;Neill, Phil Eaton, Ben Johnson, and Simon Willems.</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Using bitmaps to run interactive retention analyses over billions of events for less than $100/mo</title>
      <link>https://vikramoberoi.com/posts/using-bitmaps-to-run-interactive-retention-analyses-over-billions-of-events-for-less-than-100-mo/</link>
      <pubDate>Wed, 10 Aug 2022 00:00:00 +0000</pubDate>
      <author>hello@vikramoberoi.com (Vikram Oberoi)</author>
      <guid>https://vikramoberoi.com/posts/using-bitmaps-to-run-interactive-retention-analyses-over-billions-of-events-for-less-than-100-mo/</guid>
      <description>A dramatic encounter between a sales prospect and an account executive at a major product analytics SaaS company. Credit: katiebcartoons.com Instead of paying Mixpanel over $2K/month for retention reporting functionality in 2012 Amir Salihefendic (founder of Doist, makers of Todoist) built a clever hack to get the functionality he needed. He estimates Doist has saved millions on product analytics tools through 2022 yet has run interactive retention analyses over billions of events.</description>
      <content:encoded><![CDATA[<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/e614a450c1fd0283db24262475260ec98a78def0.png"
        alt="A cartoon of a woman being quoted $100K to run a retention analysis in a product analytics tool."
        title="A dramatic encounter between a sales prospect and an account executive at a major product analytics SaaS company. Credit: katiebcartoons.com"
    />
    <figcaption class="text-center">A dramatic encounter between a sales prospect and an account executive at a major product analytics SaaS company. Credit: <a href="https://katiebcartoons.com">katiebcartoons.com</a></figcaption>
</figure>

</p>
<p>Instead of paying <a href="mixpanel.com">Mixpanel</a> over $2K/month for retention reporting functionality in 2012 Amir Salihefendic (founder of <a href="doist.com">Doist</a>, makers of <a href="https://todoist.com/">Todoist</a>) built a clever hack to get the functionality he needed. He estimates <a href="https://twitter.com/amix3k/status/1557954159957233665">Doist has saved millions on product analytics tools through 2022</a> yet has run interactive retention analyses over billions of events.</p>
<p>Amir&rsquo;s hack was to use bitmaps and bitwise operations to answer common questions about cohorts of users. He built and open-sourced <a href="https://github.com/Doist/bitmapist">bitmapist</a>, a Python library that tracks user events in Redis bitmaps and can generate retention/cohort reports.</p>
<p>It looks like Doist still uses bitmapist in production ten years later. As of this writing, Amir himself pushed the <a href="https://github.com/Doist/bitmapist/commit/04babe58e4a50a94364ccdc1991256550f654cce">latest commit</a> three weeks ago.</p>
<p>This post walks you through how bitmapist works and its limitations/pitfalls. I won&rsquo;t cover how to read and interpret a retention report. If you&rsquo;d like a primer, <a href="https://clevertap.com/blog/cohort-analysis/">here&rsquo;s a pretty good one</a>.</p>
<hr>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/cohort-report.png"
        alt="An illustration of a daily retention report for an app. Users enter cohorts when they sign up for the app. They return when they open the app."
        title="Figure 1. A daily retention report for an app. Users enter cohorts when they sign up for the app. They return when they open the app."
    />
    <figcaption class="text-center"><strong>Figure 1.</strong> A daily retention report for an app. Users enter cohorts when they sign up for the app. They return when they open the app.</figcaption>
</figure>

</p>
<p><strong>The key idea: each cell in a retention report represents an intersection of two sets of users.</strong></p>
<p>The retention report above shows:</p>
<ul>
<li>On <em>January 27th</em>, 1,257 users signed up for our app.</li>
<li>On <em>Day 2</em>,246 (19.6%) of the <em>January 27th</em> cohort opened the app.</li>
</ul>
<p><em>Day 2</em> relative to the <em>January 27th</em> cohort is <em>January 29th</em>.</p>
<p>Thousands of users may have opened our app on January 29th, but 246 of them also signed up for our app on January 27th.</p>
<p>Here&rsquo;s another way to state the above:</p>
<pre tabindex="0"><code>A = Set of users who signed up on January 27th
B = Set of users who opened the app on January 29th

size(A) = 1257
size(B) = ?
size(A ∩ B) = 246

size(A ∩ B) / size(A) = 19.6%
</code></pre><p>Every cell in a retention report is computed the same way.</p>
<p><strong>The hack: store sets of users in bitmaps for fast set operations and high compression</strong></p>
<p>Bitmaps are an ideal data structure for this use case:</p>
<ul>
<li>Bitwise operations providefast set intersections and unions.</li>
<li>Bitmaps compress this data well. A set of 8 million users fits in 1 megabyte.</li>
</ul>
<p>Bitmapist maintains user <a href="https://redis.io/docs/data-types/bitmaps/">bitmaps in Redis</a>. Each bitmap is keyed on a <code>(time bin, event)</code> pair. If user ID 542 opens the app some time on January 25th 2022, then the bit at index 542 will be set on the bitmap with key <code>20220125:opened app</code> in the example below.</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/bitmap.png"
        alt="An illustration of how one might store user events in Redis bitmaps with daily time bins to generate the retention report in Figure 1."
        title="Figure 2. How one might store user events in Redis bitmaps with daily time bins to generate the retention report in Figure 1."
    />
    <figcaption class="text-center"><strong>Figure 2.</strong> How one might store user events in Redis bitmaps with daily time bins to generate the retention report in Figure 1.</figcaption>
</figure>

</p>
<p>Figure 2 shows daily time bins and two distinct events, but the idea generalizes to any time bin (hourly, daily, weekly, monthly, etc.) and any number of distinct events.</p>
<p>Bitwise ANDs enable high-performance set intersections to compute counts of distinct users who did our start event on one date and our return event on another date. If you&rsquo;re unfamiliar with how a bitwise AND operation works, <a href="https://en.wikipedia.org/wiki/Bitwise_operation#AND">Wikipedia has a good example</a>. Redis implements bitwise operations via its <a href="https://redis.io/commands/bitop/">BITOP command</a>.</p>
<p>Bitmapist provides a handy Python API for clients to track events (<code>mark_event(&quot;opened app&quot;, 542)</code>) and transparently maintains hour/day/week/month-binned bitmaps in the background. It also provides a library to generate retention reports interactively:</p>
<p>
<figure>
    <img
        class="mx-auto drop-shadow"
        src="images/cohort_screenshot.png"
        alt="An screenshot of a retention report generated by Bitmapist. Image from https://github.com/Doist/bitmapist."
        title="Figure 3. A retention report generated by Bitmapist. Image from https://github.com/Doist/bitmapist."
    />
    <figcaption class="text-center"><strong>Figure 3.</strong> A retention report generated by Bitmapist. Image from <a href="https://github.com/Doist/bitmapist">https://github.com/Doist/bitmapist</a>.</figcaption>
</figure>

</p>
<p><strong>Using Redis bitmaps for retention analyses is fast but extremely space-inefficient.</strong></p>
<p>The <a href="https://redis.io/commands/bitop/">Redis BITOP docs</a> link to <a href="https://blog.getspool.com/2011/11/29/fast-easy-realtime-metrics-using-redis-bitmaps/">this article from 2011</a> simulating these techniques with 128MM users. Some relevant metrics from that article:</p>
<ul>
<li><strong>Set counts and intersections over 128MM users are <em>fast</em>.</strong> Interactive retention analyses are easily achievable with this technique.
<ul>
<li>50ms to count all the bits set in one bitmap.</li>
<li>392ms to intersect 7 bitmaps (a week of daily bins).</li>
<li>1624ms to intersect 30 bitmaps (a month of daily bins).</li>
</ul>
</li>
<li><strong>A bitmap of 128MM users takes up 16MB of memory.</strong> It&rsquo;s nice to see this play out predictably. <a href="https://gist.github.com/voberoi/9fb7affa5e3d2ae3aaef7104aad8d37d">You can confirm this easily on your own machine.</a>
<ul>
<li>Remember: Each bit is a distinct user.</li>
<li>1MB stores 8 million bits.</li>
<li>Multiply that by 16 and you get 16MB and 128 million bits.</li>
</ul>
</li>
</ul>
<p>This technique is fast and compresses well, so what&rsquo;s the catch?</p>
<p>There are two:</p>
<ol>
<li><strong>Sparse bitmaps are space-inefficient in Redis.</strong> Redis will allocate as much memory as it needs to set the Nth bit. If your user&rsquo;s ID is 8,000,000, bitmapist will set that bit in Redis, which will allocate 1MB of RAM to set it. You can see this behavior in action <a href="https://gist.github.com/voberoi/9fb7affa5e3d2ae3aaef7104aad8d37d">here</a>.</li>
<li><strong>This technique stores lots of bitmaps.</strong> Let&rsquo;s say you track 100 distinct events and you bin hourly (24 * 30 bins/month), daily (30 bins/month), and monthly (1 bin/month). Worst case, you&rsquo;ll have ~75,000 bitmaps by the end of the month. If each bitmap is 1MB, Redis will allocate 75GB RAM.</li>
</ol>
<p>As of 2020, <a href="https://www.kieranflanagan.io/blog/how-todoist-went-from-a-side-project-to-millions-of-weekly-active-users">Todoist had tens of millions of signups and over one million active users</a>. That Todoist might set a bit index in the tens of millions range every day while setting few bits at a lower range (0 - 1,000,000) is highly likely.</p>
<p>Luckily, there&rsquo;s an easy fix for this that relies on more efficient bitmap representations.</p>
<p><strong>Using</strong> <a href="https://vikramoberoi.com/a-primer-on-roaring-bitmaps-what-they-are-and-how-they-work/"><strong>roaring bitmaps</strong></a> <strong>fixes the space-inefficiencies introduced by big, sparse Redis bitmaps.</strong></p>
<p>In 2017 Doist built <a href="https://github.com/Doist/bitmapist-server">a standalone bitmap server</a> to address the memory issues described above. It implements Redis&rsquo; wire protocol and the subset of Redis bitmap commands used by bitmapist so it&rsquo;s easy to swap in.</p>
<p>It is also <em>three orders of magnitude more space-efficient</em> than using Redis bitmaps under a heavily used bitmapist setup:</p>
<blockquote>
<p>Memory in use reported by Redis (matches RSS of the redis-server process): 129.48G.</p>
<p>With the same dataset migrated to standalone bitmapist server under the same load: RSS reported at about 300M.</p>
</blockquote>
<p>It achieves this result by using roaring bitmaps, compressed bitmaps that still offer high-performance bitwise operations.</p>
<p>If you&rsquo;re curious about Roaring bitmaps, I read the papers and wrote <a href="https://vikramoberoi.com/a-primer-on-roaring-bitmaps-what-they-are-and-how-they-work/">a primer on what they are and how they work</a>.</p>
<p><strong>Interactive retention analyses over billions of events for under $100/mo?</strong></p>
<p>I haven&rsquo;t tested this out but it&rsquo;s well within the realm of possibility:</p>
<ul>
<li>In 2015, <a href="https://medium.com/hacking-and-gonzo/bitmapist-analytics-and-cohorts-for-redis-44be43458ef6">Doist was storing hundreds of millions</a> events using bitmapist.</li>
<li>In 2017, Doist&rsquo;s <a href="https://github.com/Doist/bitmapist-server">roaring bitmap-based server</a> compressed at least hundreds of millions of events from ~130GB to ~300MB.</li>
<li>A factor of 10 increase in the above event volume is billions of events. Assuming memory requirements scale linearly, you&rsquo;ll need 3GB RAM.</li>
<li>Many AWS EC2 instance types will fit 3GB comfortably in physical RAM for under $100/mo.</li>
</ul>
<p>I may test this out with the <a href="https://www.gharchive.org/">Github Archive</a> dataset (~3 billion events) one day. If you take a crack at testing these assumptions before I do, please let me know so I can link to your post.</p>
<p>And if you work at Doist, let us know what hardware you run this on!</p>
<p><strong>This technique nets you affordable and efficient distinct counts, set operations, and retention analyses at scale. But the benefits end there.</strong></p>
<p>Spending $100/mo for interactive retention analyses over billions of events is incredibly affordable.</p>
<p>With an event volume in the 10&rsquo;s to 100&rsquo;s of millions per month you&rsquo;d be forking over $1000&rsquo;s/month to <a href="amplitude.com">Amplitude</a>, <a href="heapanalytics.com">Heap</a>, or <a href="mixpanel.com">Mixpanel</a> today. Amplitude&rsquo;s free tier ends at a 10MM event/mo. At that point, their annual contracts begin at ~$30K/year.</p>
<p>But these companies also provide a useful suite of tools beyond retention analyses that the techniques underlying bitmapist do not support. If you need funnel or path analyses, for example, you&rsquo;ll need to pony up the cash – this &ldquo;swiss army knife&rdquo; model of product analytics unfortunately commands a premium.</p>
<p><strong>Further reading</strong></p>
<ul>
<li><a href="https://medium.com/hacking-and-gonzo/bitmapist-analytics-and-cohorts-for-redis-44be43458ef6">The blog post introducing bitmapist</a></li>
<li><a href="https://github.com/Doist/bitmapist">Doist&rsquo;s bitmapist library on Github</a></li>
<li><a href="https://github.com/Doist/bitmapist-server">Doist&rsquo;s roaring bitmap-backed bitmap server</a></li>
<li><a href="https://roaringbitmap.org/">roaringbitmap.org</a></li>
<li><a href="https://blog.getspool.com/2011/11/29/fast-easy-realtime-metrics-using-redis-bitmaps/">An article from 2011 on real-time metrics using Redis bitmaps</a></li>
<li><a href="https://redis.io/docs/data-types/bitmaps/">Redis bitmaps documentation</a></li>
</ul>
<p><em>Thanks to Gaurav Oberoi, Chuck Groom, and Pierre Jambet.</em></p>
]]></content:encoded>
    </item>
  </channel>
</rss>