<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Data Analysis on Max Woolf&#39;s Blog</title>
    <link>https://minimaxir.com/tag/data-analysis/</link>
    <description>Recent content in Data Analysis on Max Woolf&#39;s Blog</description>
    <image>
      <title>Max Woolf&#39;s Blog</title>
      <url>https://minimaxir.com/android-chrome-512x512.png</url>
      <link>https://minimaxir.com/android-chrome-512x512.png</link>
    </image>
    <generator>Hugo</generator>
    <language>en</language>
    <copyright>Copyright Max Woolf © 2026</copyright>
    <lastBuildDate>Mon, 30 Jun 2025 10:00:00 -0700</lastBuildDate>
    <atom:link href="https://minimaxir.com/tag/data-analysis/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Predicting Average IMDb Movie Ratings Using Text Embeddings of Movie Metadata</title>
      <link>https://minimaxir.com/2025/06/movie-embeddings/</link>
      <pubDate>Mon, 30 Jun 2025 10:00:00 -0700</pubDate>
      <guid>https://minimaxir.com/2025/06/movie-embeddings/</guid>
      <description>Don&amp;rsquo;t try this in your data science interviews.</description>
      <content:encoded><![CDATA[<p>Months ago, I saw a post titled &ldquo;<a href="https://www.reddit.com/r/datascience/comments/1eykil7/rejected_from_ds_role_with_no_feedback/">Rejected from DS Role with no feedback</a>&rdquo; on Reddit&rsquo;s <a href="https://www.reddit.com/r/datascience/">Data Science subreddit</a>, in which a prospective job candidate for a data science position provided a <a href="https://colab.research.google.com/drive/1Ud2tXW2IAw_dXA5DONvNpPmmlL1foSwK">Colab Notebook</a> documenting their submission for a take-home assignment and asking for feedback as to why they were rejected. Per the Reddit user, the assignment was:</p>
<blockquote>
<p>Use the publicly available <a href="https://developer.imdb.com/non-commercial-datasets/">IMDB Datasets</a> to build a model that predicts a movie&rsquo;s average rating. Please document your approach and present your results in the notebook. Make sure your code is well-organized so that we can follow your modeling process.</p>
</blockquote>
<p><a href="https://www.imdb.com/">IMDb</a>, the Internet Movie Database owned by Amazon, allows users to rate movies on a scale from 1 to 10, wherein the average rating is then displayed prominently on the movie&rsquo;s page:</p>
<figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/shawshank_hu_fe8025c2c6a0fa89.webp 320w,/2025/06/movie-embeddings/shawshank_hu_f0b2bc74865ccb73.webp 768w,/2025/06/movie-embeddings/shawshank_hu_8f544060412f7f54.webp 1024w,/2025/06/movie-embeddings/shawshank.webp 1082w" src="shawshank.webp"
         alt="The Shawshank Redemption is currently the highest-rated movie on IMDb with an average rating of 9.3 derived from 3.1 million user votes."/> <figcaption>
            <p><a href="https://www.imdb.com/title/tt0111161/?ref_=sr_t_1">The Shawshank Redemption</a> is currently the <a href="https://www.imdb.com/search/title/?groups=top_100&amp;sort=user_rating,desc">highest-rated movie on IMDb</a> with an average rating of 9.3 derived from 3.1 million user votes.</p>
        </figcaption>
</figure>

<p>In their notebook, the Redditor identifies a few intuitive features for such a model, including the year in which the movie was released, the genre(s) of the movies, and the actors/directors of the movie. However, the model they built is a <a href="https://www.tensorflow.org/">TensorFlow</a> and <a href="https://keras.io/">Keras</a>-based neural network, with all the bells-and-whistles such as <a href="https://en.wikipedia.org/wiki/Batch_normalization">batch normalization</a> and <a href="https://en.wikipedia.org/wiki/Dilution_%28neural_networks%29">dropout</a>. The immediate response by other data scientists on /r/datascience was, at its most polite, &ldquo;why did you use a neural network when it&rsquo;s a <a href="https://en.wikipedia.org/wiki/Black_box">black box</a> that you can&rsquo;t explain?&rdquo;</p>
<p>Reading those replies made me nostalgic. Way back in 2017, before my first job as a data scientist, neural networks using frameworks such as TensorFlow and Keras were all the rage for their ability to &ldquo;<a href="https://en.wikipedia.org/wiki/Universal_approximation_theorem">solve any problem</a>&rdquo; but were often seen as lazy and unskilled compared to traditional statistical modeling such as ordinary least squares linear regression or even gradient boosted trees. Although it&rsquo;s funny to see that perception against neural networks in the data science community hasn&rsquo;t changed since, nowadays the black box nature of neural networks can be an acceptable business tradeoff if the prediction results are higher quality and interpretability is not required.</p>
<p>Looking back at the assignment description, the objective is only &ldquo;predict a movie&rsquo;s average rating.&rdquo; For data science interview take-homes, this is unusual: those assignments typically have an extra instruction along the lines of &ldquo;explain your model and what decisions stakeholders should make as a result of it&rdquo;, which is a strong hint that you need to use an explainable model like linear regression to obtain feature coefficients, or even a middle-ground like gradient boosted trees and its <a href="https://stats.stackexchange.com/questions/332960/what-is-variable-importance">variable importance</a> to quantify relative feature contribution to the model. <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> In absence of that particular constraint, it&rsquo;s arguable that anything goes, including neural networks.</p>
<p>The quality of neural networks have improved significantly since 2017, even moreso due to the massive rise of LLMs. Why not try just feeding a LLM all raw metadata for a movie and encode it into a text embedding and build a statistical model based off of that? Would a neural network do better than a traditional statistical model in that instance? Let&rsquo;s find out!</p>
<h2 id="about-imdb-data">About IMDb Data</h2>
<p>The <a href="https://developer.imdb.com/non-commercial-datasets/">IMDb Non-Commercial Datasets</a> are famous sets of data that have been around for nearly a decade <sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> but are still updated daily. Back in 2018 as a budding data scientist, I performed a <a href="https://minimaxir.com/2018/07/imdb-data-analysis/">fun exporatory data analysis</a> using these datasets, although the results aren&rsquo;t too surprising.</p>
<figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/imdb-4_hu_fdf90cbdd2dd2c7e.webp 320w,/2025/06/movie-embeddings/imdb-4_hu_1c45abe215427c09.webp 768w,/2025/06/movie-embeddings/imdb-4_hu_62d0feb034e8b054.webp 1024w,/2025/06/movie-embeddings/imdb-4.png 1200w" src="imdb-4.png"
         alt="The average rating for a movie is around 6 and tends to skew higher: a common trend in internet rating systems."/> <figcaption>
            <p>The average rating for a movie is around 6 and tends to skew higher: a common trend in internet rating systems.</p>
        </figcaption>
</figure>

<p>But in truth, these datasets are a terrible idea for companies to use for a take-home assignment. Although the datasets are released under a non-commercial license, IMDb doesn&rsquo;t want to give too much information to their competitors, which results in a severely limited amount of features that could be used to build a good predictive model. Here are the common movie-performance-related features present in the <code>title.basics.tsv.gz</code> file:</p>
<ul>
<li><strong>tconst</strong>: unique identifier of the title</li>
<li><strong>titleType</strong>: the type/format of the title (e.g. movie, tvmovie, short, tvseries, etc)</li>
<li><strong>primaryTitle</strong>: the more popular title / the title used by the filmmakers on promotional materials at the point of release</li>
<li><strong>isAdult</strong>: 0: non-adult title; 1: adult title</li>
<li><strong>startYear</strong>: represents the release year of a title.</li>
<li><strong>runtimeMinutes</strong>: primary runtime of the title, in minutes</li>
<li><strong>genres</strong>: includes up to three genres associated with the title</li>
</ul>
<p>This is a sensible schema for describing a movie, although it lacks some important information that would be very useful to determine movie quality such as production company, summary blurbs, granular genres/tags, and plot/setting — all of which are available on the IMDb movie page itself and presumably accessible through the <a href="https://developer.imdb.com/documentation/api-documentation/?ref_=/documentation/_PAGE_BODY">paid API</a>. Of note, since the assignment explicitly asks for a <em>movie</em>&rsquo;s average rating, we need to filter the data to only <code>movie</code> and <code>tvMovie</code> entries, which the original assignment failed to do.</p>
<p>The ratings data in <code>title.ratings.tsv.gz</code> is what you&rsquo;d expect:</p>
<ul>
<li><strong>tconst</strong>: unique identifier of the title (which can therefore be mapped to movie metadata using a JOIN)</li>
<li><strong>averageRating</strong>: average of all the individual user ratings</li>
<li><strong>numVotes</strong>: number of votes the title has received</li>
</ul>
<p>In order to ensure that the average ratings for modeling are indeed stable and indicative of user sentiment, I will only analyze movies that have <em>atleast 30 user votes</em>: as of May 10th 2025, that&rsquo;s about 242k movies total. Additionally, I will not use <code>numVotes</code> as a model feature, since that&rsquo;s a metric based more on extrinsic movie popularity rather than the movie itself.</p>
<p>The last major dataset is <code>title.principals.tsv.gz</code>, which has very helpful information on metadata such as the roles people play in the production of a movie:</p>
<ul>
<li><strong>tconst</strong>: unique identifier of the title (which can be mapped to movie data using a JOIN)</li>
<li><strong>nconst</strong>: unique identifier of the principal (this is mapped to <code>name.basics.tsv.gz</code> to get the principal&rsquo;s <code>primaryName</code>, but nothing else useful)</li>
<li><strong>category</strong>: the role the principal served in the title, such as <code>actor</code>, <code>actress</code>, <code>writer</code>, <code>producer</code>, etc.</li>
<li><strong>ordering</strong>: the ordering of the principals within the title, which correlates to the order the principals appear on IMDb&rsquo;s movie cast pages.</li>
</ul>
<p>Additionally, because the datasets are so popular, it&rsquo;s not the first time someone has built a IMDb ratings predictor and it&rsquo;s easy to Google.</p>
<figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/google_hu_b09e979836a71049.webp 320w,/2025/06/movie-embeddings/google_hu_c652438955f310d8.webp 768w,/2025/06/movie-embeddings/google.webp 1000w" src="google.webp"/> 
</figure>

<p>Instead of using the official IMDb datasets, these analyses are based on the smaller <a href="https://www.kaggle.com/datasets/carolzhangdc/imdb-5000-movie-dataset/data">IMDB 5000 Movie Dataset</a> hosted on Kaggle, which adds metadata such as movie rating, budget, and further actor metadata that make building a model much easier (albeit &ldquo;number of likes on the lead actor&rsquo;s Facebook page&rdquo; is <em>very</em> extrinsic to movie quality). Using the official datasets with much less metadata is building the models on hard mode and will likely have lower predictive performance.</p>
<p>Although IMDb data is very popular and very well documented, that doesn&rsquo;t mean it&rsquo;s easy to work with.</p>
<h2 id="the-initial-assignment-and-feature-engineering">The Initial Assignment and &ldquo;Feature Engineering&rdquo;</h2>
<p>Data science take-home assignments are typically 1/2 <a href="https://en.wikipedia.org/wiki/Exploratory_data_analysis">exploratory data analysis</a> for identifying impactful dataset features, and 1/2 building, iterating, and explaining the model. For real-world datasets, these are all very difficult problems with many difficult solutions, and the goal from the employer&rsquo;s perspective is seeing more <em>how</em> these problems are solved rather than the actual quantitative results.</p>
<p>The initial Reddit post decided to engineer some expected features using <a href="https://pandas.pydata.org/">pandas</a>, such as <code>is_sequel</code> by checking whether a non-<code>1</code> number is present at the end of a movie title and <a href="https://en.wikipedia.org/wiki/One-hot">one-hot encoding</a> each distinct <code>genre</code> of a movie. These are fine for an initial approach, albeit sequel titles can be idiosyncratic and it suggests that a more <a href="https://www.ibm.com/think/topics/natural-language-processing">NLP</a> approach to identifying sequels and other related media may be useful.</p>
<p>The main trick with this assignment is how to handle the principals. The common data science approach would be to use a sparse binary encoding of the actors/directors/writers, e.g. using a vector where actors present in the movie are <code>1</code> and every other actor is <code>0</code>, which leads to a large number of potential approaches to encode this data performantly, such as scikit-learn&rsquo;s <a href="https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.MultiLabelBinarizer.html">MultiLabelBinarizer</a>. The problem with this approach is that there are a <em>very</em> large number of unique actors / <a href="https://docs.honeycomb.io/get-started/basics/observability/concepts/high-cardinality/">high cardinality</a> — more unique actors than data points themselves — which leads to <a href="https://en.wikipedia.org/wiki/Curse_of_dimensionality">curse of dimensionality</a> issues and workarounds such as encoding only the top <em>N</em> actors will lead to the feature being uninformative since even a generous <em>N</em> will fail to capture the majority of actors.</p>
<figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/actor_cum_dist_hu_6b3839329e455b7d.webp 320w,/2025/06/movie-embeddings/actor_cum_dist_hu_b3985aca3321429a.webp 768w,/2025/06/movie-embeddings/actor_cum_dist_hu_27acda9c003abad5.webp 1024w,/2025/06/movie-embeddings/actor_cum_dist.png 1500w" src="actor_cum_dist.png"
         alt="There are actually 624k unique actors in this dataset (Jupyter Notebook), the chart just becomes hard to read at that point."/> <figcaption>
            <p>There are actually 624k unique actors in this dataset (<a href="https://github.com/minimaxir/imdb-embeddings/blob/main/actor_agg.ipynb">Jupyter Notebook</a>), the chart just becomes hard to read at that point.</p>
        </figcaption>
</figure>

<p>Additionally, most statistical modeling approaches cannot account for the <code>ordering</code> of actors as they treat each feature as independent, and since the billing order of actors is generally correlated to their importance in the movie, that&rsquo;s an omission of relevant information to the problem.</p>
<p>These constraints gave me an idea: why not use an LLM to encode <em>all</em> movie data, and build a model using the downstream embedding representation? LLMs have <a href="https://en.wikipedia.org/wiki/Attention_%28machine_learning%29">attention mechanisms</a>, which will not only respect the relative ordering of actors (to give higher predictive priority to higher-billed actors, along with actor cooccurrences), but also identify patterns within movie name texts (to identify sequels and related media semantically).</p>
<p>I started by aggregating and denormalizing all the data locally (<a href="https://github.com/minimaxir/imdb-embeddings/blob/main/imdb_polars_etl_test.ipynb">Jupyter Notebook</a>). Each of the IMDb datasets are hundreds of megabytes and hundreds of thousands of rows at minimum: not quite <a href="https://en.wikipedia.org/wiki/Big_data">big data</a>, but enough to be more cognizant of tooling especially since computationally-intensive JOINs are required. Therefore, I used the <a href="https://pola.rs/">Polars</a> library in Python, which not only loads data super fast, but is also one of the <a href="https://duckdblabs.github.io/db-benchmark/">fastest libraries at performing JOINs</a> and other aggregation tasks. Polars&rsquo;s syntax also allows for some cool tricks: for example, I want to spread out and aggregate the principals (4.1 million rows after prefiltering) for each movie into directors, writers, producers, actors, and all other principals into nested lists while simultaneously having them sorted by <code>ordering</code> as noted above. This is much easier to do in Polars than any other data processing library I&rsquo;ve used, and on millions of rows, this takes <em>less than a second</em>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="n">df_principals_agg</span> <span class="o">=</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">df_principals</span><span class="o">.</span><span class="n">sort</span><span class="p">([</span><span class="s2">&#34;tconst&#34;</span><span class="p">,</span> <span class="s2">&#34;ordering&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">group_by</span><span class="p">(</span><span class="s2">&#34;tconst&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">agg</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">director_names</span><span class="o">=</span><span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;primaryName&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;category&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s2">&#34;director&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="n">writer_names</span><span class="o">=</span><span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;primaryName&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;category&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s2">&#34;writer&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="n">producer_names</span><span class="o">=</span><span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;primaryName&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;category&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s2">&#34;producer&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="n">actor_names</span><span class="o">=</span><span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;primaryName&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;category&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">is_in</span><span class="p">([</span><span class="s2">&#34;actor&#34;</span><span class="p">,</span> <span class="s2">&#34;actress&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">        <span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="n">principal_names</span><span class="o">=</span><span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;primaryName&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="o">~</span><span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;category&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">is_in</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">                <span class="p">[</span><span class="s2">&#34;director&#34;</span><span class="p">,</span> <span class="s2">&#34;writer&#34;</span><span class="p">,</span> <span class="s2">&#34;producer&#34;</span><span class="p">,</span> <span class="s2">&#34;actor&#34;</span><span class="p">,</span> <span class="s2">&#34;actress&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">            <span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="n">principal_roles</span><span class="o">=</span><span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;category&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="o">~</span><span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;category&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">is_in</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">                <span class="p">[</span><span class="s2">&#34;director&#34;</span><span class="p">,</span> <span class="s2">&#34;writer&#34;</span><span class="p">,</span> <span class="s2">&#34;producer&#34;</span><span class="p">,</span> <span class="s2">&#34;actor&#34;</span><span class="p">,</span> <span class="s2">&#34;actress&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">            <span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span></code></pre></div><p>After some cleanup and field renaming, here&rsquo;s an example JSON document for <a href="https://www.imdb.com/title/tt0076759/">Star Wars: Episode IV - A New Hope</a>:</p>
<!-- prettier-ignore-start -->
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;title&#34;</span><span class="p">:</span> <span class="s2">&#34;Star Wars: Episode IV - A New Hope&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;genres&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Action&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Adventure&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Fantasy&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;is_adult&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;release_year&#34;</span><span class="p">:</span> <span class="mi">1977</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;runtime_minutes&#34;</span><span class="p">:</span> <span class="mi">121</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;directors&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;George Lucas&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;writers&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;George Lucas&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;producers&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Gary Kurtz&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Rick McCallum&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;actors&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Mark Hamill&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Harrison Ford&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Carrie Fisher&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Alec Guinness&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Peter Cushing&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Anthony Daniels&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Kenny Baker&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Peter Mayhew&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;David Prowse&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;Phil Brown&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;principals&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;John Williams&#34;</span><span class="p">:</span> <span class="s2">&#34;composer&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;Gilbert Taylor&#34;</span><span class="p">:</span> <span class="s2">&#34;cinematographer&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;Richard Chew&#34;</span><span class="p">:</span> <span class="s2">&#34;editor&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;T.M. Christopher&#34;</span><span class="p">:</span> <span class="s2">&#34;editor&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;Paul Hirsch&#34;</span><span class="p">:</span> <span class="s2">&#34;editor&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;Marcia Lucas&#34;</span><span class="p">:</span> <span class="s2">&#34;editor&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;Dianne Crittenden&#34;</span><span class="p">:</span> <span class="s2">&#34;casting_director&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;Irene Lamb&#34;</span><span class="p">:</span> <span class="s2">&#34;casting_director&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;Vic Ramos&#34;</span><span class="p">:</span> <span class="s2">&#34;casting_director&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;John Barry&#34;</span><span class="p">:</span> <span class="s2">&#34;production_designer&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><!-- prettier-ignore-end -->
<p>I was tempted to claim that I used zero feature engineering, but that wouldn&rsquo;t be accurate. The selection and ordering of the JSON fields here is itself feature engineering: for example, <code>actors</code> and <code>principals</code> are intentionally last in this JSON encoding because they can have wildly varying lengths while the prior fields are more consistent, which should make downstream encodings more comparable and consistent.</p>
<p>Now, let&rsquo;s discuss how to convert these JSON representations of movies into embeddings.</p>
<h2 id="creating-and-visualizing-the-movie-embeddings">Creating And Visualizing the Movie Embeddings</h2>
<p>LLMs that are trained to output text embeddings are not much different from LLMs like <a href="https://chatgpt.com/">ChatGPT</a> that just predict the next token in a loop. Models such as BERT and GPT can generate &ldquo;embeddings&rdquo; out-of-the-box by skipping the prediction heads of the models and instead taking an encoded value from the last hidden state of the model (e.g. for BERT, the first positional vector of the hidden state representing the <code>[CLS]</code> token). However, text embedding models are more optimized for distinctiveness of a given input text document using <a href="https://lilianweng.github.io/posts/2021-05-31-contrastive/">contrastive learning</a>. These embeddings can be used for many things, from finding similar encoded inputs by identifying the similarity between embeddings, and of course, by building a statistical model on top of them.</p>
<p>Text embeddings that leverage LLMs are typically generated using a GPU in batches due to the increased amount of computation needed. Python libraries such as <a href="https://huggingface.co/">Hugging Face</a> <a href="https://huggingface.co/docs/transformers/en/index">transformers</a> and <a href="https://sbert.net/">sentence-transformers</a> can load these embeddings models. For this experiment, I used the very new <a href="https://huggingface.co/Alibaba-NLP/gte-modernbert-base">Alibaba-NLP/gte-modernbert-base</a> text embedding model that is finetuned from the <a href="https://huggingface.co/answerdotai/ModernBERT-base">ModernBERT model</a> specifically for the embedding use case for two reasons: it uses the ModernBERT architecture which is <a href="https://huggingface.co/blog/modernbert">optimized for fast inference</a>, and the base ModernBERT model is trained to be more code-aware and should be able understand JSON-nested input strings more robustly — that&rsquo;s also why I intentionally left in the indentation for nested JSON arrays as it&rsquo;s semantically meaningful and <a href="https://huggingface.co/answerdotai/ModernBERT-base/blob/main/tokenizer_config.json">explicitly tokenized</a>. <sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup></p>
<p>The code (<a href="https://github.com/minimaxir/imdb-embeddings/blob/main/generate_imdb_embeddings.ipynb">Jupyter Notebook</a>) — with extra considerations to avoid running out of memory on either the CPU or GPU <sup id="fnref:4"><a href="#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup> — looks something like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="n">device</span> <span class="o">=</span> <span class="s2">&#34;cuda:0&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">dataloader</span> <span class="o">=</span> <span class="n">torch</span><span class="o">.</span><span class="n">utils</span><span class="o">.</span><span class="n">data</span><span class="o">.</span><span class="n">DataLoader</span><span class="p">(</span><span class="n">docs</span><span class="p">,</span> <span class="n">batch_size</span><span class="o">=</span><span class="mi">32</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                                         <span class="n">shuffle</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                                         <span class="n">pin_memory</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                                         <span class="n">pin_memory_device</span><span class="o">=</span><span class="n">device</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">dataset_embeddings</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">batch</span> <span class="ow">in</span> <span class="n">tqdm</span><span class="p">(</span><span class="n">dataloader</span><span class="p">,</span> <span class="n">smoothing</span><span class="o">=</span><span class="mi">0</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">tokenized_batch</span> <span class="o">=</span> <span class="n">tokenizer</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">batch</span><span class="p">,</span> <span class="n">max_length</span><span class="o">=</span><span class="mi">8192</span><span class="p">,</span> <span class="n">padding</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">truncation</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">return_tensors</span><span class="o">=</span><span class="s2">&#34;pt&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">to</span><span class="p">(</span><span class="n">device</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">torch</span><span class="o">.</span><span class="n">no_grad</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">outputs</span> <span class="o">=</span> <span class="n">model</span><span class="p">(</span><span class="o">**</span><span class="n">tokenized_batch</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">embeddings</span> <span class="o">=</span> <span class="n">outputs</span><span class="o">.</span><span class="n">last_hidden_state</span><span class="p">[:,</span> <span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">detach</span><span class="p">()</span><span class="o">.</span><span class="n">cpu</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">dataset_embeddings</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">embeddings</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">dataset_embeddings</span> <span class="o">=</span> <span class="n">torch</span><span class="o">.</span><span class="n">cat</span><span class="p">(</span><span class="n">dataset_embeddings</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">dataset_embeddings</span> <span class="o">=</span> <span class="n">F</span><span class="o">.</span><span class="n">normalize</span><span class="p">(</span><span class="n">dataset_embeddings</span><span class="p">,</span> <span class="n">p</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span> <span class="n">dim</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/featured_hu_be15fd7c96cd6da2.webp 320w,/2025/06/movie-embeddings/featured_hu_a1d4e8d783c0419.webp 768w,/2025/06/movie-embeddings/featured_hu_1aa1372a6affcdc5.webp 1024w,/2025/06/movie-embeddings/featured.webp 1318w" src="featured.webp"/> 
</figure>

<p>I used a Spot <a href="https://cloud.google.com/blog/products/compute/introducing-g2-vms-with-nvidia-l4-gpus">L4 GPU</a> on <a href="https://cloud.google.com/">Google Cloud Platform</a> at a pricing of $0.28/hour, and it took 21 minutes to encode all 242k movie embeddings: about $0.10 total, which is surprisingly efficient.</p>
<p>Each of these embeddings is a set of 768 numbers (768D). If the embeddings are unit normalized (the <code>F.normalize()</code> step), then calculating the dot product between embeddings will return the <a href="https://en.wikipedia.org/wiki/Cosine_similarity">cosine similarity</a> of those movies, which can then be used to identify the most similar movies. But &ldquo;similar&rdquo; is open-ended, as there are many dimensions how a movie could be considered similar.</p>
<p>Let&rsquo;s try a few movie similarity test cases where I calculate the cosine similarity between one query movie and <em>all</em> movies, then sort by cosine similarity to find the most similar (<a href="https://github.com/minimaxir/imdb-embeddings/blob/main/movie_embeddings_similarity.ipynb">Jupyter Notebook</a>). How about Peter Jackson&rsquo;s <a href="https://www.imdb.com/title/tt0120737/">Lord of the Rings: The Fellowship of the Ring</a>? Ideally, not only would it surface the two other movies of the original trilogy, but also its prequel Hobbit trilogy.</p>
<table>
  <thead>
      <tr>
          <th>title</th>
          <th>cossim</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0120737/">The Lord of the Rings: The Fellowship of the Ring (2001)</a></td>
          <td>1.0</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0167261/">The Lord of the Rings: The Two Towers (2002)</a></td>
          <td>0.922</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0167260/">The Lord of the Rings: The Return of the King (2003)</a></td>
          <td>0.92</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt10127200/">National Geographic: Beyond the Movie - The Lord of the Rings: The Fellowship of the Ring (2001)</a></td>
          <td>0.915</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0301246/">A Passage to Middle-earth: The Making of &lsquo;Lord of the Rings&rsquo; (2001)</a></td>
          <td>0.915</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0299105/">Quest for the Ring (2001)</a></td>
          <td>0.906</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0077869/">The Lord of the Rings (1978)</a></td>
          <td>0.893</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt2310332/">The Hobbit: The Battle of the Five Armies (2014)</a></td>
          <td>0.891</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt1170358/">The Hobbit: The Desolation of Smaug (2013)</a></td>
          <td>0.883</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0903624/">The Hobbit: An Unexpected Journey (2012)</a></td>
          <td>0.883</td>
      </tr>
  </tbody>
</table>
<p>Indeed, it worked and surfaced both trilogies! The other movies listed are about the original work, so having high similarity would be fair.</p>
<p>Compare these results to the &ldquo;<a href="https://help.imdb.com/article/imdb/discover-watch/what-is-the-more-like-this-section/GPE7SPGZREKKY7YN">More like this</a>&rdquo; section on the IMDb page for the movie itself, which has the two sequels to the original Lord of the Rings and two other suggestions that I am not entirely sure are actually related.</p>
<figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/lotr_related_hu_7560f67c8d88cb97.webp 320w,/2025/06/movie-embeddings/lotr_related_hu_544b4f2cf95b01dd.webp 768w,/2025/06/movie-embeddings/lotr_related_hu_8c4f2099751f082.webp 1024w,/2025/06/movie-embeddings/lotr_related.webp 1354w" src="lotr_related.webp"/> 
</figure>

<p>What about more elaborate franchises, such as the <a href="https://en.wikipedia.org/wiki/Marvel_Cinematic_Universe">Marvel Cinematic Universe</a>? If you asked for movies similar to <a href="https://www.imdb.com/title/tt4154796/">Avengers: Endgame</a>, would other MCU films be the most similar?</p>
<table>
  <thead>
      <tr>
          <th>title</th>
          <th>cossim</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="https://www.imdb.com/title/tt4154796/">Avengers: Endgame (2019)</a></td>
          <td>1.0</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt4154756/">Avengers: Infinity War (2018)</a></td>
          <td>0.909</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0848228/">The Avengers (2012)</a></td>
          <td>0.896</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt1217616/">Endgame (2009)</a></td>
          <td>0.894</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt4154664/">Captain Marvel (2019)</a></td>
          <td>0.89</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt2395427/">Avengers: Age of Ultron (2015)</a></td>
          <td>0.882</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt3498820/">Captain America: Civil War (2016)</a></td>
          <td>0.882</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0292502/">Endgame (2001)</a></td>
          <td>0.881</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0118661/">The Avengers (1998)</a></td>
          <td>0.877</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt1228705/">Iron Man 2 (2010)</a></td>
          <td>0.876</td>
      </tr>
  </tbody>
</table>
<p>The answer is yes, which isn&rsquo;t a surprise since those movies share many principals. Although, there are instances of other movies named &ldquo;Endgame&rdquo; and &ldquo;The Avengers&rdquo; which are completely unrelated to Marvel and therefore implies that the similarities may be fixated on the names.</p>
<p>What about movies of a smaller franchise but a specific domain, such as Disney&rsquo;s <a href="https://www.imdb.com/title/tt2294629/">Frozen</a> that only has one sequel? Would it surface other 3D animated movies by <a href="https://en.wikipedia.org/wiki/Walt_Disney_Animation_Studios">Walt Disney Animation Studios</a>, or something else?</p>
<table>
  <thead>
      <tr>
          <th>title</th>
          <th>cossim</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="https://www.imdb.com/title/tt2294629/">Frozen (2013)</a></td>
          <td>1.0</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt4520988/">Frozen II (2019)</a></td>
          <td>0.93</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt1323045/">Frozen (2010)</a></td>
          <td>0.92</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt1611845/">Frozen (2010)</a> [a different one]</td>
          <td>0.917</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0125279/">Frozen (1996)</a></td>
          <td>0.909</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt0376606/">Frozen (2005)</a></td>
          <td>0.9</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt2363439/">The Frozen (2012)</a></td>
          <td>0.898</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt4007494/">The Story of Frozen: Making a Disney Animated Classic (2014)</a></td>
          <td>0.894</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt1071798/">Frozen (2007)</a></td>
          <td>0.889</td>
      </tr>
      <tr>
          <td><a href="https://www.imdb.com/title/tt4150316/">Frozen in Time (2014)</a></td>
          <td>0.888</td>
      </tr>
  </tbody>
</table>
<p>&hellip;okay, it&rsquo;s definitely fixating on the name. Let&rsquo;s try a different approach to see if we can find more meaningful patterns in these embeddings.</p>
<p>In order to visualize the embeddings, we can project them to a lower dimensionality with a dimensionality reduction algorithm such as <a href="https://en.wikipedia.org/wiki/Principal_component_analysis">PCA</a> or <a href="https://umap-learn.readthedocs.io/en/latest/">UMAP</a>: UMAP is preferred as it can simultaneously reorganize the data into more meaningful clusters. UMAP&rsquo;s <a href="https://umap-learn.readthedocs.io/en/latest/how_umap_works.html">construction of a neighborhood graph</a>, in theory, can allow the reduction to refine the similarities by leveraging many possible connections and hopefully avoid fixating on the movie name. However, with this amount of input data and the relatively high initial 768D vector size, the computation cost of UMAP is a concern as both factors each cause the UMAP training time to scale exponentially. Fortunately, NVIDIA&rsquo;s <a href="https://github.com/rapidsai/cuml">cuML library</a> recently <a href="https://github.com/rapidsai/cuml/releases/tag/v25.04.00">updated</a> and now you can run UMAP with very high amounts of data on a GPU at a very high number of epochs to ensure the reduction fully converges, so I did just that (<a href="https://github.com/minimaxir/imdb-embeddings/blob/main/imdb_embeddings_umap_to_2D.ipynb">Jupyter Notebook</a>). What patterns can we find? Let&rsquo;s try plotting the reduced points, colored by their user rating.</p>
<figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/imdb_umap_rating_hu_4047e53667cc289a.webp 320w,/2025/06/movie-embeddings/imdb_umap_rating_hu_74d5c85f14c8950c.webp 768w,/2025/06/movie-embeddings/imdb_umap_rating_hu_2b6ccdbb5b4b9105.webp 1024w,/2025/06/movie-embeddings/imdb_umap_rating.webp 1200w" src="imdb_umap_rating.webp"/> 
</figure>

<p>So there&rsquo;s a few things going on here. Indeed, most of the points are high-rating green as evident in the source data. But the points and ratings aren&rsquo;t <em>random</em> and there are trends. In the center giga cluster, there are soft subclusters of movies at high ratings and low ratings. Smaller discrete clusters did indeed form, but what is the deal with that extremely isolated cluster at the top? After investigation, that cluster only has movies released in 2008, which is another feature I should have considered when defining movie similarity.</p>
<p>As a sanity check, I faceted out the points by movie release year to better visualize where these clusters are forming:</p>
<figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/imdb_umap_rating_year_hu_40c4d6844e346f92.webp 320w,/2025/06/movie-embeddings/imdb_umap_rating_year_hu_48d37fbda72976cc.webp 768w,/2025/06/movie-embeddings/imdb_umap_rating_year_hu_27485860dc95d177.webp 1024w,/2025/06/movie-embeddings/imdb_umap_rating_year.webp 1200w" src="imdb_umap_rating_year.webp"/> 
</figure>

<p>This shows that even the clusters movies have their values spread, but I unintentionally visualized how <a href="https://arize.com/docs/ax/machine-learning/computer-vision/how-to-cv/embedding-drift">embedding drift</a> changes over time. 2024 is also a bizarrely-clustered year: I have no idea why those two years specifically are weird in movies.</p>
<p>The UMAP approach is more for fun, since it&rsquo;s better for the downstream model building to use the raw 768D vector and have it learn the features from that. At the least, there&rsquo;s <em>some</em> semantic signal preserved in these embeddings, which makes me optimistic that these embeddings alone can be used to train a viable movie rating predictor.</p>
<h2 id="predicting-average-imdb-movie-scores">Predicting Average IMDb Movie Scores</h2>
<p>So, we now have hundreds of thousands of 768D embeddings. How do we get them to predict movie ratings? What many don&rsquo;t know is that all methods of traditional statistical modeling also work with embeddings — assumptions such as feature independence are invalid so the results aren&rsquo;t explainable, but you can still get a valid predictive model.</p>
<p>First, we will shuffle and split the data set into a training set and a test set: for the test set, I chose 20,000 movies (roughly 10% of the data) which is more than enough for stable results. To decide the best model, we will be using the model that minimizes the <a href="https://en.wikipedia.org/wiki/Mean_squared_error">mean squared error</a> (MSE) of the test set, which is a standard approach to solving regression problems that predict a single numeric value.</p>
<p>Here are three approaches for using LLMs for solving non-next-token-prediction tasks.</p>
<h3 id="method-1-traditional-modeling-w-gpu-acceleration">Method #1: Traditional Modeling (w/ GPU Acceleration!)</h3>
<p>You can still fit a linear regression on top of the embeddings even if feature coefficients are completely useless and it serves as a decent baseline (<a href="https://github.com/minimaxir/imdb-embeddings/blob/main/cuml_grid_search.ipynb">Jupyter Notebook</a>). The absolute laziest &ldquo;model&rdquo; where we just use the mean of the training set for every prediction results in a test MSE of <strong>1.637</strong>, but performing a simple linear regression on top of the 768D instead results in a more reasonable test MSE of <strong>1.187</strong>. We should be able to beat that handily with a more advanced model.</p>
<p>Data scientists familiar with scikit-learn know there&rsquo;s a rabbit hole of model options, but most of them are CPU-bound and single-threaded and would take considerable amount of time on a dataset of this size. That&rsquo;s where cuML—the same library I used to create the UMAP projection—comes in, as cuML has <a href="https://docs.rapids.ai/api/cuml/stable/api/#regression-and-classification">GPU-native implementations</a> of most popular scikit-learn models with a similar API. This notably includes <a href="https://en.wikipedia.org/wiki/Support_vector_machine">support vector machines</a>, which play especially nice with embeddings. And because we have the extra compute, we can also perform a brute force hyperparameter <a href="https://www.dremio.com/wiki/grid-search/">grid search</a> to find the best parameters for fitting each model.</p>
<p>Here&rsquo;s the results of MSE on the test dataset for a few of these new model types, with the hyperparameter combination for each model type that best minimizes MSE:</p>
<figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/model_comparison_base_hu_2e224af8e7736cd2.webp 320w,/2025/06/movie-embeddings/model_comparison_base_hu_ea8ec94f59331bc5.webp 768w,/2025/06/movie-embeddings/model_comparison_base_hu_536396210f6f6e7a.webp 1024w,/2025/06/movie-embeddings/model_comparison_base.png 1200w" src="model_comparison_base.png"/> 
</figure>

<p>The winner is the Support Vector Machine, with a test MSE of <strong>1.087</strong>! This is a good start for a simple approach that handily beats the linear regression baseline, and it also beats the model training from the Redditor&rsquo;s original notebook which had a test MSE of 1.096 <sup id="fnref:5"><a href="#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup>. In all cases, the train set MSE was close to the test set MSE, which means the models did not overfit either.</p>
<h3 id="method-2-neural-network-on-top-of-embeddings">Method #2: Neural Network on top of Embeddings</h3>
<p>Since we&rsquo;re already dealing with AI models and already have PyTorch installed to generate the embeddings, we might as well try the traditional approach of training a <a href="https://en.wikipedia.org/wiki/Multilayer_perceptron">multilayer perceptron</a> (MLP) neural network on top of the embeddings (<a href="https://github.com/minimaxir/imdb-embeddings/blob/main/pytorch_model_train_mlp.ipynb">Jupyter Notebook</a>). This workflow sounds much more complicated than just fitting a traditional model above, but PyTorch makes MLP construction straightforward, and Hugging Face&rsquo;s <a href="https://huggingface.co/docs/transformers/en/main_classes/trainer">Trainer class</a> incorporates best model training practices by default, although its <code>compute_loss</code> function has to be tweaked to minimize MSE specifically.</p>
<p>The PyTorch model, using a loop to set up the MLP blocks, looks something like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">RatingsModel</span><span class="p">(</span><span class="n">nn</span><span class="o">.</span><span class="n">Module</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">linear_dims</span><span class="o">=</span><span class="mi">256</span><span class="p">,</span> <span class="n">num_layers</span><span class="o">=</span><span class="mi">6</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="n">dims</span> <span class="o">=</span> <span class="p">[</span><span class="mi">768</span><span class="p">]</span> <span class="o">+</span> <span class="p">[</span><span class="n">linear_dims</span><span class="p">]</span> <span class="o">*</span> <span class="n">num_layers</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">mlp</span> <span class="o">=</span> <span class="n">nn</span><span class="o">.</span><span class="n">ModuleList</span><span class="p">([</span>
</span></span><span class="line"><span class="cl">            <span class="n">nn</span><span class="o">.</span><span class="n">Sequential</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">                <span class="n">nn</span><span class="o">.</span><span class="n">Linear</span><span class="p">(</span><span class="n">dims</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="n">dims</span><span class="p">[</span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="p">]),</span>
</span></span><span class="line"><span class="cl">                <span class="n">nn</span><span class="o">.</span><span class="n">GELU</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">                <span class="n">nn</span><span class="o">.</span><span class="n">BatchNorm1d</span><span class="p">(</span><span class="n">dims</span><span class="p">[</span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="p">]),</span>
</span></span><span class="line"><span class="cl">                <span class="n">nn</span><span class="o">.</span><span class="n">Dropout</span><span class="p">(</span><span class="mf">0.6</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="p">)</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">dims</span><span class="p">)</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="p">])</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">output</span> <span class="o">=</span> <span class="n">nn</span><span class="o">.</span><span class="n">Linear</span><span class="p">(</span><span class="n">dims</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">],</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">,</span> <span class="n">targets</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">layer</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">mlp</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">x</span> <span class="o">=</span> <span class="n">layer</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">output</span><span class="p">(</span><span class="n">x</span><span class="p">)</span><span class="o">.</span><span class="n">squeeze</span><span class="p">()</span>  <span class="c1"># return 1D output if batched inputs</span>
</span></span></code></pre></div><p>This MLP is 529k parameters total: large for a MLP, but given the 222k row input dataset, it&rsquo;s not egregiously so.</p>
<p>The real difficulty with this MLP approach is that it&rsquo;s <em>too effective</em>: even with less than 1 million parameters, the model will extremely overfit and converge to 0.00 train MSE quickly, while the test set MSE explodes. That&rsquo;s why <code>Dropout</code> is set to the atypically high probability of <code>0.6</code>.</p>
<p>Fortunately, MLPs are fast to train: training for 600 epochs (total passes through the full training dataset) took about 17 minutes on the GPU. Here&rsquo;s the training results:</p>
<figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/training_mlp_hu_db4d2b769213c385.webp 320w,/2025/06/movie-embeddings/training_mlp_hu_99fc40ac0f82af11.webp 768w,/2025/06/movie-embeddings/training_mlp_hu_c64c2a10817470c0.webp 1024w,/2025/06/movie-embeddings/training_mlp.png 1200w" src="training_mlp.png"/> 
</figure>

<p>The lowest logged test MSE was <strong>1.074</strong>: a slight improvement over the Support Vector Machine approach.</p>
<h3 id="method-3-just-train-a-llm-from-scratch-dammit">Method #3: Just Train a LLM From Scratch Dammit</h3>
<p>There is a possibility that using a pretrained embedding model that was trained on the entire internet could intrinsically contain relevant signal about popular movies—such as movies winning awards which would imply a high IMDb rating—and that knowledge could leak into the test set and provide misleading results. This may not be a significant issue in practice since it&rsquo;s such a small part of the <code>gte-modernbert-base</code> model which is too small to memorize exact information.</p>
<p>For the sake of comparison, let&rsquo;s try training a LLM from scratch on top of the raw movie JSON representations to process this data to see if we can get better results without the possibility of leakage (<a href="https://github.com/minimaxir/imdb-embeddings/blob/main/pytorch_model_train_llm.ipynb">Jupyter Notebook</a>). I was specifically avoiding this approach because the compute required to train an LLM is much, much higher than a SVM or MLP model and generally leveraging a pretrained model gives better results. In this case, since we don&rsquo;t need a LLM that has all the knowledge of human existence, we can train a much smaller model that <em>only</em> knows how to work with the movie JSON representations and can figure out relationships between actors and whether titles are sequels itself. Hugging Face transformers makes this workflow surprisingly straightforward by not only having functionality to train your own custom tokenizer (in this case, from 50k vocab to 5k vocab) that encodes the data more efficiently, but also allowing the construction a ModernBERT model with any number of layers and units. I opted for a 5M parameter LLM (SLM?), albeit with less dropout since high dropout causes learning issues for LLMs specifically.</p>
<p>The actual PyTorch model code is surprisingly more concise than the MLP approach:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">RatingsModel</span><span class="p">(</span><span class="n">nn</span><span class="o">.</span><span class="n">Module</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">transformer_model</span> <span class="o">=</span> <span class="n">model</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">output</span> <span class="o">=</span> <span class="n">nn</span><span class="o">.</span><span class="n">Linear</span><span class="p">(</span><span class="n">hidden_size</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">input_ids</span><span class="p">,</span> <span class="n">attention_mask</span><span class="p">,</span> <span class="n">targets</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">x</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">transformer_model</span><span class="o">.</span><span class="n">forward</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">input_ids</span><span class="o">=</span><span class="n">input_ids</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">attention_mask</span><span class="o">=</span><span class="n">attention_mask</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">output_hidden_states</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">x</span> <span class="o">=</span> <span class="n">x</span><span class="o">.</span><span class="n">last_hidden_state</span><span class="p">[:,</span> <span class="mi">0</span><span class="p">]</span>  <span class="c1"># the &#34;[CLS] vector&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">output</span><span class="p">(</span><span class="n">x</span><span class="p">)</span><span class="o">.</span><span class="n">squeeze</span><span class="p">()</span>  <span class="c1"># return 1D output if batched inputs</span>
</span></span></code></pre></div><p>Essentially, the model trains its own &ldquo;text embedding,&rdquo; although in this case instead of an embedding optimized for textual similarity, the embedding is just a representation that can easily be translated into a numeric rating.</p>
<p>Because the computation needed for training a LLM from scratch is much higher, I only trained the model for 10 epochs, which was still twice as slow than the 600 epochs for the MLP approach. Given that, the results are surprising:</p>
<figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/training_llm_hu_2355de410bfc61c1.webp 320w,/2025/06/movie-embeddings/training_llm_hu_cfcd114ac3c12003.webp 768w,/2025/06/movie-embeddings/training_llm_hu_f6c75fc2deeead45.webp 1024w,/2025/06/movie-embeddings/training_llm.png 1200w" src="training_llm.png"/> 
</figure>

<p>The LLM approach did much better than my previous attempts with a new lowest test MSE of <strong>1.026</strong>, with only 4 passes through the data! And then it definitely overfit. I tried other smaller configurations for the LLM to avoid the overfitting, but none of them ever hit a test MSE that low.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Let&rsquo;s look at the model comparison again, this time adding the results from training a MLP and training a LLM from scratch:</p>
<figure>

    <img loading="lazy" srcset="/2025/06/movie-embeddings/model_comparison_all_hu_2309fb0cea20f0c.webp 320w,/2025/06/movie-embeddings/model_comparison_all_hu_34af566430bbc603.webp 768w,/2025/06/movie-embeddings/model_comparison_all_hu_1e1d9cf8cdfde789.webp 1024w,/2025/06/movie-embeddings/model_comparison_all.png 1200w" src="model_comparison_all.png"/> 
</figure>

<p>Coming into this post, I&rsquo;m genuinely thought that training the MLP on top of embeddings would have been the winner given the base embedding model&rsquo;s knowledge of everything, but maybe there&rsquo;s something to just YOLOing and feeding raw JSON input data to a completely new LLM. More research and development is needed.</p>
<p>The differences in model performance from these varying approaches aren&rsquo;t dramatic, but some iteration is indeed interesting and it was a long shot anyways given the scarce amount of metadata. The fact that building a model off of text embeddings only didn&rsquo;t result in a perfect model doesn&rsquo;t mean this approach was a waste of time. The embedding and modeling pipelines I have constructed in the process of trying to solve this problem have already provided significant dividends on easier problems, such as identifying the efficiency of <a href="https://minimaxir.com/2025/02/embeddings-parquet/">storing embeddings in Parquet and manipulating them with Polars</a>.</p>
<p>It&rsquo;s impossible and pointless to pinpoint the exact reason the original Reddit poster got rejected: it could have been the neural network approach or even something out of their control such as the original company actually stopping hiring and being too disorganized to tell the candidate. To be clear, if I myself were to apply for a data science role, I wouldn&rsquo;t use the techniques in this blog post (that UMAP data visualization would get me instantly rejected!) and do more traditional EDA and non-neural-network modeling to showcase my data science knowledge to the hiring manager. But for my professional work, I will definitely try starting any modeling exploration with an embeddings-based approach wherever possible: at the absolute worst, it&rsquo;s a very strong baseline that will be hard to beat.</p>
<p><em>All of the Jupyter Notebooks and data visualization code for this blog post is available open-source in <a href="https://github.com/minimaxir/imdb-embeddings/">this GitHub repository</a>.</em></p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>I am not a fan of using GBT variable importance as a decision-making metric: variable importance does not tell you magnitude or <em>direction</em> of the feature in the real world, but it does help identify which features can be pruned for model development iteration.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>To get a sense on how old they are, they are only available as <a href="https://en.wikipedia.org/wiki/Tab-separated_values">TSV files</a>, which is a data format so old and prone to errors that many data libraries have dropped explicit support for it. Amazon, please release the datasets as CSV or Parquet files instead!&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3">
<p>Two other useful features of <code>gte-modernbert-base</code> but not strictly relevant to these movie embeddings are a) its a cased model so it can identify meaning from upper-case text and b) it does not require a prefix such as <code>search_query</code> and <code>search_document</code> as <a href="https://huggingface.co/nomic-ai/nomic-embed-text-v1.5">nomic-embed-text-v1.5 does</a> to guide its results, which is an annoying requirement for those models.&#160;<a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4">
<p>The trick here is the <code>detach()</code> function for the computed embeddings, otherwise the GPU doesn&rsquo;t free up the memory once moved back to the CPU. I may or may not have discovered that the hard way.&#160;<a href="#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:5">
<p>As noted earlier, minimizing MSE isn&rsquo;t a competition, but the comparison on roughly the same dataset is good for a sanity check.&#160;<a href="#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded>
    </item>
    <item>
      <title>The Best Way to Use Text Embeddings Portably is With Parquet and Polars</title>
      <link>https://minimaxir.com/2025/02/embeddings-parquet/</link>
      <pubDate>Mon, 24 Feb 2025 10:15:00 -0800</pubDate>
      <guid>https://minimaxir.com/2025/02/embeddings-parquet/</guid>
      <description>Never store embeddings in a CSV!</description>
      <content:encoded><![CDATA[<p><a href="https://stackoverflow.blog/2023/11/09/an-intuitive-introduction-to-text-embeddings/">Text embeddings</a>, particularly modern embeddings generated from large language models, are one of the most useful applications coming from the generative AI boom. Embeddings are a list of numbers which represent an object: in the case of text embeddings, they can represent words, sentences, and full paragraphs and documents, and they do so with a surprising amount of distinctiveness.</p>
<p>Recently, I created text embeddings representing every distinct <a href="https://magic.wizards.com/en">Magic: the Gathering</a> card released as of the February 2025 Aetherdrift expansion: 32,254 in total. With these embeddings, I can find the mathematical similarity between cards through the encoded representation of their card design, including all mechanical attributes such as the card name, card cost, card text, and even card rarity.</p>
<figure>

    <img loading="lazy" srcset="/2025/02/embeddings-parquet/wog_hu_7ed6be2e5737eeb4.webp 320w,/2025/02/embeddings-parquet/wog_hu_81c75e037d833a96.webp 768w,/2025/02/embeddings-parquet/wog.webp 976w" src="wog.webp"
         alt="The iconic Magic card Wrath of God, along with its top four most similar cards identified using their respective embeddings. The similar cards are valid matches, with similar card text and card types."/> <figcaption>
            <p>The iconic Magic card <a href="https://gatherer.wizards.com/pages/card/Details.aspx?multiverseid=129808">Wrath of God</a>, along with its top four most similar cards identified using their respective embeddings. The similar cards are valid matches, with similar card text and card types.</p>
        </figcaption>
</figure>

<p>Additionally, I can create a fun 2D <a href="https://umap-learn.readthedocs.io/en/latest/">UMAP</a> projection of all those cards, which also identifies interesting patterns:</p>
<figure>

    <img loading="lazy" srcset="/2025/02/embeddings-parquet/mtg_umap_hu_df72981641ef0ffd.webp 320w,/2025/02/embeddings-parquet/mtg_umap_hu_ad2e63ba61f377cd.webp 768w,/2025/02/embeddings-parquet/mtg_umap_hu_7de8f113f1eb20fa.webp 1024w,/2025/02/embeddings-parquet/mtg_umap.webp 1200w" src="mtg_umap.webp"
         alt="The UMAP dimensionality reduction process also implicitly clusters the Magic cards to logical clusters, such as by card color(s) and card type."/> <figcaption>
            <p>The UMAP dimensionality reduction process also implicitly clusters the Magic cards to logical clusters, such as by card color(s) and card type.</p>
        </figcaption>
</figure>

<p>I generated these Magic card embeddings for <em>something special</em> besides a pretty data visualization, but if you are curious how I generated them, they were made using the new-but-underrated <a href="https://huggingface.co/Alibaba-NLP/gte-modernbert-base">gte-modernbert-base</a> embedding model and the process is detailed <a href="https://github.com/minimaxir/mtg-embeddings">in this GitHub repository</a>. The embeddings themselves (including the coordinate values to reproduce the 2D UMAP visualization) are available as a <a href="https://huggingface.co/datasets/minimaxir/mtg-embeddings">Hugging Face dataset</a>.</p>
<p>Most tutorials involving embedding generation omit the obvious question: what do you <em>do</em> with the text embeddings after you generate them? The common solution is to use a <a href="https://en.wikipedia.org/wiki/Vector_database">vector database</a>, such as <a href="https://github.com/facebookresearch/faiss">faiss</a> or <a href="https://qdrant.tech">qdrant</a>, or even a cloud-hosted service such as <a href="https://www.pinecone.io">Pinecone</a>. But those aren&rsquo;t easy to use: faiss has <a href="https://github.com/facebookresearch/faiss/wiki/Guidelines-to-choose-an-index">confusing configuration options</a>, qdrant requires <a href="https://github.com/qdrant/qdrant?tab=readme-ov-file#client-server">using a Docker container</a> to host the storage server, and Pinecone can get <a href="https://www.pinecone.io/pricing/">very expensive</a> very quickly, and its free Starter tier is limited.</p>
<p>What many don&rsquo;t know about text embeddings is that you don&rsquo;t <em>need</em> a vector database to calculate nearest-neighbor similarity if your data isn&rsquo;t too large. Using <a href="https://numpy.org/doc/stable/index.html">numpy</a> and my Magic card embeddings, a 2D matrix of 32,254 <code>float32</code> embeddings at a dimensionality of 768D (common for &ldquo;smaller&rdquo; LLM embedding models) occupies <strong>94.49 MB</strong> of system memory, which is relatively low for modern personal computers and can fit within free usage tiers of cloud VMs. If both the query vector and the embeddings themselves are unit normalized (many embedding generators normalize by default), then the matrix dot product between the query and embeddings results in a cosine similarity between <code>[-1, 1]</code>, where the higher score is better/more similar. Since dot products are such a fundamental aspect of linear algebra, numpy&rsquo;s implementation is extremely fast: with the help of additional numpy <a href="https://numpy.org/doc/stable/reference/generated/numpy.argpartition.html">sorting</a> <a href="https://numpy.org/doc/2.1/reference/generated/numpy.argsort.html">shenanigans</a>, on my M3 Pro MacBook Pro it takes just <strong>1.08 ms</strong> on average to calculate all 32,254 dot products, find the top 3 most similar embeddings, and return their corresponding <code>idx</code> of the matrix and and cosine similarity <code>score</code>.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">fast_dot_product</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">matrix</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="mi">3</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">dot_products</span> <span class="o">=</span> <span class="n">query</span> <span class="o">@</span> <span class="n">matrix</span><span class="o">.</span><span class="n">T</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">idx</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">argpartition</span><span class="p">(</span><span class="n">dot_products</span><span class="p">,</span> <span class="o">-</span><span class="n">k</span><span class="p">)[</span><span class="o">-</span><span class="n">k</span><span class="p">:]</span>
</span></span><span class="line"><span class="cl">    <span class="n">idx</span> <span class="o">=</span> <span class="n">idx</span><span class="p">[</span><span class="n">np</span><span class="o">.</span><span class="n">argsort</span><span class="p">(</span><span class="n">dot_products</span><span class="p">[</span><span class="n">idx</span><span class="p">])[::</span><span class="o">-</span><span class="mi">1</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">score</span> <span class="o">=</span> <span class="n">dot_products</span><span class="p">[</span><span class="n">idx</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">idx</span><span class="p">,</span> <span class="n">score</span>
</span></span></code></pre></div><p>In most implementations of vector databases, once you insert the embeddings, they&rsquo;re stuck there in a proprietary serialization format and you are locked into that library and service. If you&rsquo;re just building a personal pet project or sanity-checking embeddings to make sure the results are good, that&rsquo;s a huge amount of friction. For example, when I want to experiment with embeddings, I generate them on a cloud server with a GPU since LLM-based embeddings models are often slow to generate without one, and then download them locally to my personal computer. What is the best way to handle embeddings portably such that they can easily be moved between machines and also in a non-proprietary format?</p>
<p>The answer, after much personal trial-and-error, is Parquet files, which still has a surprising amount of nuance. But before we talk about why Parquet files are good, let&rsquo;s talk about how <em>not</em> to store embeddings.</p>
<h2 id="the-worst-ways-to-store-embeddings">The Worst Ways to Store Embeddings</h2>
<p>The incorrect-but-unfortunately-common way to store embeddings is in a text format such as a CSV file. Text data is substantially larger than <code>float32</code> data: for example, a decimal number with full precision (e.g. <code>2.145829051733016968e-02</code>) as a <code>float32</code> is 32 bits/4 bytes, while as a text representation (in this case 24 ASCII <code>char</code>s) it&rsquo;s 24 bytes, <strong>6x larger</strong>. When the CSV is saved and loaded, the data has to be serialized between a numpy and a string representation of the array, which adds significant overhead. Despite that, in <a href="https://github.com/openai/openai-cookbook/blob/a3e98ea4dcf866b5e7a3cb7d63dccaa68c7d63aa/examples/Embedding_Wikipedia_articles_for_search.ipynb">one of OpenAI&rsquo;s official tutorials</a> for their embeddings models, they save the embeddings as a CSV using <a href="https://pandas.pydata.org">pandas</a> with the admitted caveat of &ldquo;Because this example only uses a few thousand strings, we&rsquo;ll store them in a CSV file. (For larger datasets, use a vector database, which will be more performant.)&rdquo;. In the case of the Magic card embeddings, pandas-to-CSV performs the <em>worst</em> out of any encoding options: more on why later.</p>
<p>Numpy has native methods to <a href="https://numpy.org/doc/stable/reference/generated/numpy.savetxt.html">save</a> and <a href="https://numpy.org/doc/stable/reference/generated/numpy.loadtxt.html">load</a> embeddings as a <code>.txt</code> that&rsquo;s straightforward:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="n">np</span><span class="o">.</span><span class="n">savetxt</span><span class="p">(</span><span class="s2">&#34;embeddings_txt.txt&#34;</span><span class="p">,</span> <span class="n">embeddings</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">embeddings_r</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">loadtxt</span><span class="p">(</span><span class="s2">&#34;embeddings_txt.txt&#34;</span><span class="p">,</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">float32</span><span class="p">,</span> <span class="n">delimiter</span><span class="o">=</span><span class="s2">&#34; &#34;</span><span class="p">)</span>
</span></span></code></pre></div><p>The resulting file not only takes a few seconds to save and load, but it&rsquo;s also massive: <strong>631.5 MB</strong>!</p>
<p>As an aside, HTTP APIs such as OpenAI&rsquo;s <a href="https://platform.openai.com/docs/guides/embeddings">Embeddings API</a> do transmit the embeddings over text which adds needless latency and bandwidth overhead. I wish more embedding providers offered <a href="https://grpc.io">gRPC</a> APIs which allow transfer of binary <code>float32</code> data instead to gain a performance increase: Pinecone&rsquo;s <a href="https://docs.pinecone.io/reference/python-sdk">Python SDK</a>, for example, does just that.</p>
<p>The second incorrect method to save a matrix of embeddings to disk is to save it as a Python <a href="https://docs.python.org/3/library/pickle.html">pickle</a> object, which stores its representation in memory on disk with a few lines of code from the native <code>pickle</code> library. Pickling is unfortunately common in the machine learning industry since many ML frameworks such as <a href="https://scikit-learn.org/stable/">scikit-learn</a> don&rsquo;t have easy ways to serialize encoders and models. But it comes with two major caveats: pickled files are a massive security risk as they can execute arbitrary code, and the pickled file may not be guaranteed to be able to be opened on other machines or Python versions. It&rsquo;s 2025, just stop pickling if you can.</p>
<p>In the case of the Magic card embeddings, it does indeed work with instant save/loads, and the file size on disk is <strong>94.49 MB</strong>: the same as its memory consumption and about 1/6th of the text size as expected:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="s2">&#34;embeddings_matrix.pkl&#34;</span><span class="p">,</span> <span class="s2">&#34;wb&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">pickle</span><span class="o">.</span><span class="n">dump</span><span class="p">(</span><span class="n">embeddings</span><span class="p">,</span> <span class="n">f</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="s2">&#34;embeddings_matrix.pkl&#34;</span><span class="p">,</span> <span class="s2">&#34;rb&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">embeddings_r</span> <span class="o">=</span> <span class="n">pickle</span><span class="o">.</span><span class="n">load</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>
</span></span></code></pre></div><p>But there are still better and easier approaches.</p>
<h2 id="the-intended-but-not-great-way-to-store-embeddings">The Intended-But-Not-Great Way to Store Embeddings</h2>
<p>Numpy itself has a canonical way to <a href="https://numpy.org/doc/2.1/reference/generated/numpy.save.html">save</a> and <a href="https://numpy.org/doc/2.1/reference/generated/numpy.load.html">load</a> matrixes — which annoyingly saves as a pickle by default for compatability reasons, but that can fortunately be disabled by setting <code>allow_pickle=False</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="n">np</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="s2">&#34;embeddings_matrix.npy&#34;</span><span class="p">,</span> <span class="n">embeddings</span><span class="p">,</span> <span class="n">allow_pickle</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">embeddings_r</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">load</span><span class="p">(</span><span class="s2">&#34;embeddings_matrix.npy&#34;</span><span class="p">,</span> <span class="n">allow_pickle</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span></code></pre></div><p>File size and I/O speed are the same as with the <code>pickle</code> approach.</p>
<p>This works — and it&rsquo;s something I had used for awhile — but in the process it exposes another problem: how do we map metadata (the Magic cards in this case) to embeddings? Currently, we use the <code>idx</code> of the most-similar matches to perform an efficient batched lookup to the source data. In this case, the number of rows matches the number of cards exactly, but what happens if the embeddings matrix needs to be changed, such as to add or remove cards and their embeddings? What happens if you want to add a dataset filter? It becomes a mess that inevitably causes technical debt.</p>
<p>The solution to this is to colocate metadata such as card names, card text, and attributes with their embeddings: that way, if they are later added, removed, or sorted, the results will remain the same. Modern vector databases such as qdrant and Pinecone do just that, with the ability to filter and sort on the metadata at the same time you query the most similar vectors. This is a bad idea to do in numpy itself, as it&rsquo;s more optimized for numbers and not other data types such as strings, which have <a href="https://numpy.org/devdocs/user/basics.strings.html">limited operations available</a>.</p>
<p>The solution is to look at another file format that can store metadata and embeddings simultaneously, and the answer to that is Parquet files. But there&rsquo;s a rabbit hole as to what&rsquo;s the <em>best</em> way to interact with them.</p>
<h2 id="what-are-parquet-files">What are Parquet files?</h2>
<p>Parquet, developed by the open-source <a href="https://parquet.apache.org">Apache Parquet</a> project, is a file format for handling columnar data, but despite being <a href="https://blog.x.com/engineering/en_us/a/2013/announcing-parquet-10-columnar-storage-for-hadoop">first released in 2013</a> it hasn&rsquo;t taken off in the data science community until very recently. <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> The most relevant feature of Parquet is that the resulting files are typed for each column, and that this typing includes nested lists, such as an embedding which is just a list of <code>float32</code> values. As a bonus, the columnar format allows downstream libraries to save/load them selectively and very quickly, far faster than CSVs and with rare parsing errors. The file format also allows for efficient compression and decompression, but that&rsquo;s less effective with embeddings as there&rsquo;s little redundant data.</p>
<p>For Parquet file I/O, the standard approach is to use the <a href="https://arrow.apache.org">Apache Arrow</a> protocol that is columnar in-memory, which complements the Parquet storage medium on disk. But how do you use Arrow?</p>
<h2 id="how-do-you-use-parquet-files-in-python-for-embeddings">How do you use Parquet files in Python for embeddings?</h2>
<p>Ideally, we need a library that can handle nested data easily and can interoperate with numpy for serializing to a matrix and can run fast dot products.</p>
<p>The official Arrow library that <a href="https://arrow.apache.org/docs/python/index.html">interacts with Parquet natively</a> in Python is <a href="https://arrow.apache.org/docs/python/index.html">pyarrow</a>. Here, I have an example Parquet file generated with [SPOILERS] that contains both the card metadata and an <code>embedding</code> column, with the embedding for each row corresponding to that card.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="n">df</span> <span class="o">=</span> <span class="n">pa</span><span class="o">.</span><span class="n">parquet</span><span class="o">.</span><span class="n">read_table</span><span class="p">(</span><span class="s2">&#34;mtg-embeddings.parquet&#34;</span><span class="p">)</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2025/02/embeddings-parquet/parquet_hu_268909d3d8256458.webp 320w,/2025/02/embeddings-parquet/parquet_hu_be20ddd4d423844c.webp 768w,/2025/02/embeddings-parquet/parquet_hu_dc1002cb8e03a874.webp 1024w,/2025/02/embeddings-parquet/parquet.png 1352w" src="parquet.png"
         alt="Pyarrow&rsquo;s table schema from the input Parquet file of Magic card embeddings. Note the embedding column at the bottom is a list of 768 floats."/> <figcaption>
            <p>Pyarrow&rsquo;s table schema from the input Parquet file of Magic card embeddings. Note the <code>embedding</code> column at the bottom is a list of 768 floats.</p>
        </figcaption>
</figure>

<p>But pyarrow is not a DataFrame library, and despite the data being in a Table, it&rsquo;s hard to slice and access: the documentation suggests that you export to pandas if you need more advanced manipulation.</p>
<p>Other more traditional data science libraries can leverage pyarrow directly. The most popular one is, of course, pandas itself which can <a href="https://pandas.pydata.org/docs/reference/api/pandas.read_parquet.html">read/write Parquet</a> doing just that. There are many, many resources for using pandas well, so it&rsquo;s often the first choice among data science practioners.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="n">df</span> <span class="o">=</span> <span class="n">pd</span><span class="o">.</span><span class="n">read_parquet</span><span class="p">(</span><span class="s2">&#34;mtg-embeddings.parquet&#34;</span><span class="p">,</span> <span class="n">columns</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;name&#34;</span><span class="p">,</span> <span class="s2">&#34;embedding&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl"><span class="n">df</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2025/02/embeddings-parquet/pandas_embed_hu_43da08f8256fb434.webp 320w,/2025/02/embeddings-parquet/pandas_embed_hu_ffb22e6af150d0a8.webp 768w,/2025/02/embeddings-parquet/pandas_embed_hu_f0379dc63b1b8457.webp 1024w,/2025/02/embeddings-parquet/pandas_embed.png 1224w" src="pandas_embed.png"
         alt="Pandas HTML table output of the Magic card DataFrame when printed in a Jupyter Notebook."/> <figcaption>
            <p>Pandas HTML table output of the Magic card DataFrame when printed in a Jupyter Notebook.</p>
        </figcaption>
</figure>

<p>There&rsquo;s one major weakness for the use case of embeddings: pandas is very bad at nested data. From the image above you&rsquo;ll see that the <code>embedding</code> column <em>appears</em> to be a list of numbers, but it&rsquo;s actually a list of numpy <code>object</code>s, which is a very inefficent datatype and why I suspect writing it to a CSV is very slow. Simply converting it to numpy with <code>df[&quot;embedding&quot;].to_numpy()</code> results in a 1D array, which is definitely wrong, and trying to cast it to <code>float32</code> doesn&rsquo;t work. I found that the best way to extract the embeddings matrix from a pandas <code>embedding</code> column is to <a href="https://numpy.org/doc/2.1/reference/generated/numpy.vstack.html">np.vstack()</a> the embeddings, e.g. <code>np.vstack(df[&quot;embedding&quot;].to_numpy())</code>, which does result in a <code>(32254, 768)</code> <code>float32</code> matrix as expected. That adds a lot of compute and memory overhead in addition to unnecessary numpy array copies. Finally, after computing the dot products between a candidate query and the embedding matrix, row metadata with the most similar values can then be retrieved using <code>df.loc[idx]</code>. <sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup></p>
<p>However, there is another, more recent tabular data library that not only is faster than pandas, it has proper support for nested data. That library is polars.</p>
<h2 id="the-power-of-polars">The Power of polars</h2>
<p><a href="https://pola.rs">Polars</a> is a relatively new Python library which is primarily written in <a href="https://www.rust-lang.org">Rust</a> and <a href="https://docs.pola.rs/#key-features">supports Arrow</a>, which gives it a <a href="https://duckdblabs.github.io/db-benchmark/">massive performance increase</a> over pandas and many other DataFrame libraries. In the case of Magic cards, 32k rows isn&rsquo;t nearly &ldquo;big data&rdquo; and the gains of using a high-performance library are lesser, but there are some unexpected features that coincidentally work <em>perfectly</em> for the embeddings use case.</p>
<p>As with pandas, you read a parquet file with a <code>read_parquet()</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="n">df</span> <span class="o">=</span> <span class="n">pl</span><span class="o">.</span><span class="n">read_parquet</span><span class="p">(</span><span class="s2">&#34;mtg-embeddings.parquet&#34;</span><span class="p">,</span> <span class="n">columns</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;name&#34;</span><span class="p">,</span> <span class="s2">&#34;embedding&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl"><span class="n">df</span>
</span></span></code></pre></div><figure>

    <img loading="lazy" srcset="/2025/02/embeddings-parquet/polars_embed_hu_98a1dcff6631f16f.webp 320w,/2025/02/embeddings-parquet/polars_embed_hu_7795d47fe1f2255a.webp 768w,/2025/02/embeddings-parquet/polars_embed.png 957w" src="polars_embed.png"
         alt="Polars HTML table output of the Magic card DataFrame when printed in a Jupyter Notebook."/> <figcaption>
            <p>Polars HTML table output of the Magic card DataFrame when printed in a Jupyter Notebook.</p>
        </figcaption>
</figure>

<p>There&rsquo;s a notable difference in the table output compared to <code>pandas</code>: it also reports the data type of its columns, and more importantly, it shows that the <code>embedding</code> column consists of arrays, all <code>float32</code>s, and all length 768. That&rsquo;s a great start!</p>
<p>polars also has a to_numpy() function. Unlike pandas, if you call <code>to_numpy()</code> on a column as a Series, e.g. <code>df['embedding'].to_numpy()</code>, the returned object is a numpy 2D matrix: no <code>np.vstack()</code> needed. If you look at the <a href="https://docs.pola.rs/api/python/stable/reference/series/api/polars.Series.to_numpy.html">documentation</a> for the function, there&rsquo;s a curious feature:</p>
<blockquote>
<p>This operation copies data only when necessary. The conversion is zero copy when all of the following hold: [&hellip;]</p>
</blockquote>
<p>Zero copy! And in the case of columnar-stored embeddings, the conditions will always hold, but you can set <code>allow_copy=False</code> to throw an error just in case.</p>
<p>Inversely, if you want to add a 2D embeddings matrix to an existing DataFrame and colocate each embedding&rsquo;s corresponding metadata, such as after you batch-generate thousands of embeddings and want to save and download the resulting Parquet, it&rsquo;s just as easy as adding a column to the DataFrame.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="n">df</span> <span class="o">=</span> <span class="n">pl</span><span class="o">.</span><span class="n">with_columns</span><span class="p">(</span><span class="n">embedding</span><span class="o">=</span><span class="n">embeddings</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">df</span><span class="o">.</span><span class="n">write_parquet</span><span class="p">(</span><span class="s2">&#34;mtg-embeddings.parquet&#34;</span><span class="p">)</span>
</span></span></code></pre></div><p>Now, let&rsquo;s put the speed to the test using all the Magic card metadata. What if we perform embedding similarity on a Magic card, but beforehand dynamically filter the dataset according to user parameters (therefore filtering the candidate embeddings at the same time since they are colocated) and perform the similarity calculations quickly as usual? Let&rsquo;s try with <a href="https://gatherer.wizards.com/pages/card/details.aspx?multiverseid=87908">Lightning Helix</a>, a card whose effects are self-explanatory even to those who don&rsquo;t play Magic.</p>
<figure>

    <img loading="lazy" srcset="/2025/02/embeddings-parquet/helix_1_hu_9f15db636cb74690.webp 320w,/2025/02/embeddings-parquet/helix_1_hu_c58b97e1d1c6f502.webp 768w,/2025/02/embeddings-parquet/helix_1.webp 976w" src="helix_1.webp"
         alt="The most similar cards to Lightning Helix do have similar effects, although &ldquo;Lightning&rdquo; cards dealing damage is a common trope in Magic. Warleader&rsquo;s Helix is a direct reference to Lightning Helix."/> <figcaption>
            <p>The most similar cards to Lightning Helix do have similar effects, although &ldquo;Lightning&rdquo; cards dealing damage is a common trope in Magic. <a href="https://gatherer.wizards.com/pages/card/Details.aspx?multiverseid=456806">Warleader&rsquo;s Helix</a> is a direct reference to Lightning Helix.</p>
        </figcaption>
</figure>

<p>Now we can also find similar cards to Lightning Helix but with filters. In this case, let&rsquo;s look for a Sorcery (which are analogous to Instants but tend to be stronger since they have play limitations) and has Black as one of its colors. This limits the candidates to ~3% of the original dataset. The resulting code would look like this, given a <code>query_embed</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-py3" data-lang="py3"><span class="line"><span class="cl"><span class="n">df_filter</span> <span class="o">=</span> <span class="n">df</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;type&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">str</span><span class="o">.</span><span class="n">contains</span><span class="p">(</span><span class="s2">&#34;Sorcery&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="n">pl</span><span class="o">.</span><span class="n">col</span><span class="p">(</span><span class="s2">&#34;manaCost&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">str</span><span class="o">.</span><span class="n">contains</span><span class="p">(</span><span class="s2">&#34;B&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">embeddings_filter</span> <span class="o">=</span> <span class="n">df_filter</span><span class="p">[</span><span class="s2">&#34;embedding&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">to_numpy</span><span class="p">(</span><span class="n">allow_copy</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">idx</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">fast_dot_product</span><span class="p">(</span><span class="n">query_embed</span><span class="p">,</span> <span class="n">embeddings_filter</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="mi">4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">related_cards</span> <span class="o">=</span> <span class="n">df_filter</span><span class="p">[</span><span class="n">idx</span><span class="p">]</span>
</span></span></code></pre></div><p>As an aside, in polars you can call row subsets of a DataFrame with <code>df[idx]</code>, which makes it infinitely better than pandas and its <code>df.iloc[idx]</code>.</p>
<p>The resulting similar cards:</p>
<figure>

    <img loading="lazy" srcset="/2025/02/embeddings-parquet/helix_2_hu_f6db1b1e0be3033.webp 320w,/2025/02/embeddings-parquet/helix_2_hu_1d74aa59da2a8d38.webp 768w,/2025/02/embeddings-parquet/helix_2.webp 976w" src="helix_2.webp"
         alt="In this case, the similarity focuses on card text similarity, and these cards have near identical text. Smiting Helix is also a direct reference to Lightning Helix."/> <figcaption>
            <p>In this case, the similarity focuses on card text similarity, and these cards have near identical text. <a href="https://gatherer.wizards.com/Pages/Card/Details.aspx?multiverseid=464058">Smiting Helix</a> is also a direct reference to Lightning Helix.</p>
        </figcaption>
</figure>

<p>Speed-wise, the code runs at about <strong>1.48ms</strong> on average, or about 37% slower than calculating all dot products, so the filtering does still have some overhead, which is not surprising as that the filtered dataframe does copy the embeddings. Overall, it&rsquo;s still more than fast enough for a hobby project.</p>
<p>I&rsquo;ve created an <a href="https://colab.research.google.com/drive/19C_9sBC0Py2PlXYihl2ed378oGyroONZ?usp=sharing">interactive Colab Notebook</a> where you can generate similarities for any Magic card, and apply any filters you want!</p>
<h2 id="scaling-to-vector-databases">Scaling to Vector Databases</h2>
<p>Again, all of this assumes that you are using the embeddings for smaller/noncommercial projects. If you scale to hundreds of thousands of embeddings, the parquet and dot product approach for finding similarity should still be fine, but if it&rsquo;s a business critical application, the marginal costs of querying a vector database are likely lower than the marginal revenue from a snappy similarity lookup. Deciding how to make these tradeoffs is the fun part of MLOps!</p>
<p>In the case that the amount of vectors is too large to fit into memory but you don&rsquo;t want to go all-in on vector databases, another option that may be worth considering is using an old-fashioned database that can now support vector embeddings. Notably, <a href="https://www.sqlite.org">SQLite</a> databases are just a single portable file, however interacting with them has more technical overhead and considerations than the <code>read_parquet()</code> and <code>write_parquet()</code> of polars. One notable implementation of vector databases in SQLite is the <a href="https://alexgarcia.xyz/sqlite-vec/">sqlite-vec extension</a>, which also allows for simultaneous filtering and similarity calculations.</p>
<p>The next time you&rsquo;re working with embeddings, consider whether you really need a vector database. For many applications, the combination of Parquet files and polars provides everything you need: efficient storage, fast similarity search, and easy metadata filtering. Sometimes the simplest solution is the best one.</p>
<p><em>The code used to process the Magic card data, create the embeddings, and plot the UMAP 2D projection, is all available <a href="https://github.com/minimaxir/mtg-embeddings">in this GitHub repository</a>.</em></p>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>I suspect the main bottleneck to widespread Parquet support is Microsoft Excel&rsquo;s and other spreadsheet software&rsquo;s lack of native support for the format. Every data scientist will be very, very happy if/when they do!&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>OpenAI&rsquo;s <a href="https://github.com/openai/openai-cookbook/blob/main/examples/Question_answering_using_embeddings.ipynb">approach</a> using pandas to find colocated similarity is to manually iterate through the entire dataframe, calculate each cosine similarity between the candidate and the query for each row, then sort by scores. That implementation definitely does not scale.&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded>
    </item>
    <item>
      <title>The Super Effectiveness of Pokémon Embeddings Using Only Raw JSON and Images</title>
      <link>https://minimaxir.com/2024/06/pokemon-embeddings/</link>
      <pubDate>Wed, 26 Jun 2024 10:00:00 -0700</pubDate>
      <guid>https://minimaxir.com/2024/06/pokemon-embeddings/</guid>
      <description>Embeddings encourage engineers to go full YOLO because it&amp;rsquo;s actually rewarding to do so!</description>
      <content:encoded><![CDATA[<p><a href="https://en.wikipedia.org/wiki/Word_embedding">Embeddings</a> are one of the most useful but unfortunately underdiscussed concepts in the artificial intelligence space relative to the modern generative AI gigahype. Embeddings are a set of hundreds of numbers which uniquely correspond to a given object that define its dimensionality, nowadays in a multiple of 128 such as 384D, 768D, or even 1536D. <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> The larger the embeddings, the more &ldquo;information&rdquo; and distinctiveness each can contain, in theory. These embeddings can be used as-is for traditional <a href="https://en.wikipedia.org/wiki/Regression_analysis">regression</a> and <a href="https://en.wikipedia.org/wiki/Statistical_classification">classification</a> problems with your favorite statistical modeling library, but what&rsquo;s really useful about these embeddings is that if you can find the minimum mathematical distance between a given query embedding and another set of embeddings, you can then find which is the most similar: extremely useful for many real-world use cases such as search.</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/embedding_demo_hu_d3f88226f240b184.webp 320w,/2024/06/pokemon-embeddings/embedding_demo_hu_2c5a706d1ebace78.webp 768w,/2024/06/pokemon-embeddings/embedding_demo_hu_34918a6ff306a4ac.webp 1024w,/2024/06/pokemon-embeddings/embedding_demo.png 1178w" src="embedding_demo.png"
         alt="An example sentence embedding generated using Sentence Transformers: this embedding is 384D."/> <figcaption>
            <p>An example sentence embedding generated using Sentence Transformers: this embedding is 384D.</p>
        </figcaption>
</figure>

<p>Although any kind of object can be represented by an embedding, text is the classical use case for embeddings, popularized with the original <a href="https://en.wikipedia.org/wiki/Word2vec">word2vec</a> <a href="https://arxiv.org/abs/1301.3781">paper</a> which along with <a href="https://arxiv.org/abs/1310.4546">later work</a> showed that word embeddings could be used to calculate relationships such as man + women - king = queen. You could then, for example, create a sentence embedding by averaging all of its word embeddings. This actually works, although this naive averaging does not take word position and punctuation into account, both of which are critically important in identifying context for a given text.</p>
<p>Deep learning then entered the picture and it was eventually discovered that large language models like <a href="https://en.wikipedia.org/wiki/BERT_%28language_model%29">BERT</a> can return embeddings as an emergent behavior. Unlike the word averaging above, <a href="https://en.wikipedia.org/wiki/Transformer_%28deep_learning_architecture%29">transformers</a>-based LLMs can account for positional relationships more robustly thanks to their <a href="https://en.wikipedia.org/wiki/Attention_%28machine_learning%29">attention mechanisms</a>, and, due to their more advanced model input <a href="https://www.freecodecamp.org/news/how-tokenizers-shape-ai-understanding/">tokenization</a> strategies than just words, can also better incorporate punctuation. One very popular Python library for creating embeddings using LLMs easily is <a href="https://sbert.net">Sentence Transformers</a>, especially with the <a href="https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2">all-MiniLM-L6-v2</a> model (<a href="https://huggingface.co/models?pipeline_tag=sentence-similarity&amp;sort=downloads">30 million downloads monthly</a>!) which balances embedding encoding speed and robustness with its 384D embeddings.</p>
<p>How well can these embeddings models work beyond just normal sentences? Can they encode larger bodies of text into a consistent space? The context length of <code>all-MiniLM-L6-v2</code> is 512 tokens, which can only fit a couple paragraphs of text, but newer LLMs have much higher context lengths.</p>
<p>I recalled one of my early projects as an aspiring data scientist: creating <a href="https://github.com/minimaxir/pokemon-3d">Pokémon vectors</a> by <a href="https://github.com/minimaxir/pokemon-3d/blob/master/pokemon_spark_pca.ipynb">manually transforming Pokémon metadata</a> for each Pokémon, such as their base stats, type(s), moves, abilities, and miscellaneous attributes such as color, shape, and habitat. After that, I was able to cluster them.</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/cluster_hu_7ca1c5b82a702cc7.webp 320w,/2024/06/pokemon-embeddings/cluster.png 676w" src="cluster.png"
         alt="3D projection of my Pokémon vectors back in 2016: the colors are Pokémon types, and the methodology seemed to favor clustering by them."/> <figcaption>
            <p>3D projection of my Pokémon vectors back in 2016: the colors are Pokémon types, and the methodology seemed to favor clustering by them.</p>
        </figcaption>
</figure>

<p>Those familar with Pokémon know that&rsquo;s just scratching the surface: there&rsquo;s even more metadata such as the rich text data such as a Pokémon&rsquo;s Pokédex entries and the exact locations where they can be encountered, both of which tell a lot about a given Pokémon. At the time, there was no efficient LLM to encode all of that extra metadata.</p>
<p>Why not try to encode all Pokémon metadata using a text embedding model and see what happens? Will we be able to identify the most &ldquo;similar&rdquo; Pokémon? What is a &ldquo;similar&rdquo; Pokémon anyways? Can we find the <em>weirdest</em> Pokémon by the most dissimilar? Can we encode other Pokémon data such as images? Let&rsquo;s find out!</p>
<h2 id="how-embeddings-are-generated-using-llms">How Embeddings Are Generated Using LLMs</h2>
<p>First, some relevant technical background on how LLMs can be used to create embeddings since there&rsquo;s surprisingly a lot of confusion about how they work other than the SEO-oriented &ldquo;embeddings are for <a href="https://www.cloudflare.com/learning/ai/what-is-vector-database/">vector databases</a>&rdquo;.</p>
<p>Modern embedding models are commonly trained through one of two ways. The first way is through emergent behavior while training an LLM normally: as LLMs need to determine a latent space before passing the output to a classification head such as <a href="https://en.wikipedia.org/wiki/Generative_pre-trained_transformer">GPT</a>&rsquo;s next-token prediction, taking the last layer (&ldquo;hidden state&rdquo;) of a model and averaging across the positional axis results in an embedding with the same dimensionality as the hidden state. LLMs have to learn how to uniquely represent text in a common latent space, so this is approach is natural. The second way is to train a model to output the embeddings directly: in this case, the training process typically uses <a href="https://encord.com/blog/guide-to-contrastive-learning/">contrastive learning</a> to minimize the semantic distance between the generated embeddings of a pair of known text documents, and maximize the difference between a dissimilar pair. Both of these techniques can be used together of course: pretrain a LLM on a large body of text, then finetune it with contrastive learning.</p>
<p>Embeddings models get the benefits of all the research invested into improving LLMs for generative AI, such as inference speed and longer context windows. Normally it requires a quadratic increase in computation to use those larger context windows (e.g. a 2<em>x</em> increase in input length requires 4<em>x</em> more computation), but thanks to <a href="https://huggingface.co/docs/text-generation-inference/en/conceptual/flash_attention">FlashAttention</a> and <a href="https://blog.eleuther.ai/rotary-embeddings/">rotary positional embeddings,</a> it&rsquo;s now feasible to train models with massively-large context windows without a massive datacenter and then run those models on consumer hardware.</p>
<p>Ever since 2022, <a href="https://openai.com">OpenAI</a> had the text embedding model <a href="https://openai.com/index/new-and-improved-embedding-model/">text-embedding-ada-002</a> behind a paid API with the largest context window of 8,192 tokens: a substantial increase over <code>all-MiniLM-L6-v2</code>&rsquo;s 512 limit, and no other open-source model could compete. That is until February 2024, when <a href="https://www.nomic.ai">Nomic AI</a> released <a href="https://huggingface.co/nomic-ai/nomic-embed-text-v1">nomic-embed-text-v1</a>, a fully open-source embeddings model with a 8,192 context window and a permissive <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache license</a>, and quickly followed up with <a href="https://huggingface.co/nomic-ai/nomic-embed-text-v1.5">nomic-embed-text-v1.5</a>. In academic benchmarks, this free model performed even better than OpenAI&rsquo;s paid embedding model <a href="https://blog.nomic.ai/posts/nomic-embed-text-v1">thanks to its training regimen</a> that uses both embedding model training tricks described above. That, along with its long context window, caused it to become another one of the most downloaded open-source embedding models (~10 million downloads per month).</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/nomic_embeddings_demo_hu_4dadd5c725d77e60.webp 320w,/2024/06/pokemon-embeddings/nomic_embeddings_demo_hu_6682e6dc986b6850.webp 768w,/2024/06/pokemon-embeddings/nomic_embeddings_demo_hu_1c6ffa82964e1090.webp 1024w,/2024/06/pokemon-embeddings/nomic_embeddings_demo.png 1340w" src="nomic_embeddings_demo.png"
         alt="A sentence embedding generated using nomic-embed-text-v1.5 adapted from the official example: this is a lower-level interface than Sentence Transformers (Hugging Face transformers and PyTorch) but is more clear as to what is going on. mean_pooling() uses an atypical attention-masked averaging that is theoretically better for small inputs than averaging the entire last hidden state."/> <figcaption>
            <p>A sentence embedding generated using <code>nomic-embed-text-v1.5</code> adapted from the official example: this is a lower-level interface than Sentence Transformers (<a href="https://huggingface.co/docs/transformers/en/index">Hugging Face transformers</a> and <a href="https://pytorch.org">PyTorch</a>) but is more clear as to what is going on. <code>mean_pooling()</code> uses an atypical attention-masked averaging that is theoretically better for small inputs than averaging the entire last hidden state.</p>
        </figcaption>
</figure>

<p>The <code>F.normalize()</code> function is a popular pipeline innovation in finding similar embeddings efficiently. <sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> A <a href="https://en.wikipedia.org/wiki/Unit_vector">unit normalized</a> vector has a vector length summing to 1. But if you perform a matrix multiplication (an extremely fast computational operation) of a normalized vector against a matrix of normalized vectors, then the result will be the <a href="https://en.wikipedia.org/wiki/Cosine_similarity">cosine similarity</a>, constrained between the values of 1 for identical matches and -1 for the most dissimilar matches.</p>
<p>Now that we have thoroughly covered how embeddings work, let&rsquo;s see if we can put that 8,192 context window to the test.</p>
<h2 id="what-kind-of-pokémon-embedding-are-you">What Kind of Pokémon Embedding Are You?</h2>
<p>Before encoding Pokémon data, I need to first get Pokémon data, but where? Nintendo certainly won&rsquo;t have an API for Pokémon data, and web scraping a Pokémon wiki such as <a href="https://bulbapedia.bulbagarden.net/wiki/Main_Page">Bulbapedia</a> is both impractical and rude. Fortunately, there&rsquo;s an unofficial Pokémon API known appropriately as <a href="https://pokeapi.co">PokéAPI</a>, which is both open source and has been around for years without Nintendo taking them down. Of note, PokéAPI has a <a href="https://beta.pokeapi.co/graphql/console/">GraphQL interface</a> to its Pokémon data, allowing you to query exactly what you want without having to do relationship mapping or data joins.</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/graphql_hu_545910786cbd7bf6.webp 320w,/2024/06/pokemon-embeddings/graphql_hu_5d76d10c482e4154.webp 768w,/2024/06/pokemon-embeddings/graphql_hu_d03bd1849088a102.webp 1024w,/2024/06/pokemon-embeddings/graphql.png 1260w" src="graphql.png"
         alt="A simple GraphQL query to get all Pokémon IDs and names, sorted by ID."/> <figcaption>
            <p>A simple GraphQL query to get all Pokémon IDs and names, sorted by ID.</p>
        </figcaption>
</figure>

<p>Since we can get Pokémon data in a nicely structured JSON dictionary, why not keep it that way? After writing a <a href="https://github.com/minimaxir/pokemon-embeddings/blob/main/query.gql">massive GraphQL query</a> to specify all mechanically relevant Pokémon data, all it takes it a single GET request to download it all, about 16MB of data total. This includes over 1,000 Pokémon up to the Scarlet/Violet <em>The Hidden Treasure of Area Zero</em> DLC: 1,302 Pokémon total if you include the Special forms of Pokémon (e.g. <a href="https://bulbapedia.bulbagarden.net/wiki/Mega_Evolution">Mega Evolutions</a>) which I&rsquo;m excluding for simplicity.</p>
<p>As an example, let&rsquo;s start with the franchise mascot, <a href="https://bulbapedia.bulbagarden.net/wiki/Pikachu_%28Pok%C3%A9mon%29">Pikachu</a>.</p>
<figure class="align-center ">

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/025_hu_94d9a33563b85a33.webp 320w,/2024/06/pokemon-embeddings/025.png 475w" src="025.png#center"
         alt="The iconic Pokémon #25. via Nintendo" width="300" height="300"/> <figcaption>
            <p>The iconic Pokémon #25. <a href="https://www.pokemon.com/us/pokedex/pikachu">via Nintendo</a></p>
        </figcaption>
</figure>

<p>Here&rsquo;s a subset of Pikachu&rsquo;s <a href="https://github.com/minimaxir/pokemon-embeddings/blob/main/pikachu_example_raw.json">JSON metadata</a> from that query:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="mi">25</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;pikachu&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;height&#34;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;weight&#34;</span><span class="p">:</span> <span class="mi">60</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;base_experience&#34;</span><span class="p">:</span> <span class="mi">112</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;pokemon_v2_pokemontypes&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;pokemon_v2_type&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;electric&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;pokemon_v2_pokemonstats&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;pokemon_v2_stat&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;hp&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;base_stat&#34;</span><span class="p">:</span> <span class="mi">35</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="err">...</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;pokemon_v2_pokemonspecy&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;base_happiness&#34;</span><span class="p">:</span> <span class="mi">50</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;capture_rate&#34;</span><span class="p">:</span> <span class="mi">190</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;forms_switchable&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;gender_rate&#34;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;has_gender_differences&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;hatch_counter&#34;</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;is_baby&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;is_legendary&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;is_mythical&#34;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;pokemon_v2_pokemonspeciesflavortexts&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;pokemon_v2_version&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;red&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;flavor_text&#34;</span><span class="p">:</span> <span class="s2">&#34;When several of\nthese POK\u00e9MON\ngather, their\felectricity could\nbuild and cause\nlightning storms.&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="err">...</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;pokemon_v2_pokemonmoves&#34;</span><span class="err">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nt">&#34;pokemon_v2_move&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;mega-punch&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nt">&#34;pokemon_v2_type&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;normal&#34;</span>
</span></span><span class="line"><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="err">...</span>
</span></span></code></pre></div><p>There&rsquo;s definitely no shortage of Pikachu data! Some of the formatting is redundant though: most of the JSON keys have a <code>pokemon_v2_</code> string that conveys no additional semantic information, and we can minify the JSON to remove all the whitespace. We won&rsquo;t experiment with more rigorous preprocessing: after all, I only need to optimize an ETL workflow if it <em>doesn&rsquo;t</em> work, right?</p>
<p>Since JSON data is so prevalent across the internet, it&rsquo;s extremely likely that a newly trained LLM will be sensitive to its schema and be able to understand it better. However, JSON is a token-inefficient encoding format, made even worse in this case by the particular choice of tokenizer. Here&rsquo;s the distribution of the encoded texts after the optimizations above, using <code>nomic-embed-text-v1.5</code>&rsquo;s text tokenizer which is incidentally the same <a href="https://huggingface.co/google-bert/bert-base-uncased">bert-based-uncased</a> tokenizer used for BERT back in 2018:</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/token_counts_hu_400e5e984e326eb1.webp 320w,/2024/06/pokemon-embeddings/token_counts_hu_cf5bcc5547d45eb.webp 768w,/2024/06/pokemon-embeddings/token_counts_hu_e4d28e56e2dc7bc9.webp 1024w,/2024/06/pokemon-embeddings/token_counts.png 1200w" src="token_counts.png"/> 
</figure>

<p>The 8,192 context length of <code>nomic-embed-text-v1.5</code> is perfect for fitting almost all Pokémon! But the median token count is 3,781 tokens which is still somewhat high. The reason for this is due to the tokenizer: bert-base-uncased is a <a href="https://huggingface.co/learn/nlp-course/en/chapter6/6">WordPiece</a> tokenizer which is optimized for words and their common prefixes and suffixes, while JSON data is highly structured. If you use a more modern tokenizer which utilizes <a href="https://en.wikipedia.org/wiki/Byte_pair_encoding">byte pair encoding</a> (BPE), such as the <code>o200k_base</code> tokenizer which powers OpenAI&rsquo;s <a href="https://openai.com/index/hello-gpt-4o/">GPT-4o</a>, then the median token count is 2,010 tokens: nearly half the size, and therefore would be much faster to process the embeddings.</p>
<p>After that, I <a href="https://github.com/minimaxir/pokemon-embeddings/blob/main/pokemon_embeddings.ipynb">encoded all the Pokémon metadata</a> into a 768D text embedding for each and every Pokémon, including unit normalization. Due to the quadratic scaling at high input token counts, this is still very computationally intensive despite the optimization tricks: for the 1,302 embeddings, it took about a half-hour on a <a href="https://colab.research.google.com">Google Colab</a> T4 GPU. The embeddings are then saved on disk in a <a href="https://parquet.apache.org">parquet</a> format, a tabular format which supports nesting sequences of floats natively (don&rsquo;t use a CSV to store embeddings!). The embedding generation is the hard part, now it&rsquo;s time for the fun part!</p>
<p>Let&rsquo;s start off with Pikachu. What Pokémon is Pikachu most similar to, i.e. has the highest cosine similarity? Remember, since all the embeddings are normalized, we can get all the cosine similairites by matrix multiplying the Pikachu embedding against all the other embeddings. Let&rsquo;s include the top 3 of each of Pokémon&rsquo;s nine (!) generations to date:</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/25_similar_text_hu_d3abb73fceff08ae.webp 320w,/2024/06/pokemon-embeddings/25_similar_text_hu_7f6bea07a378c8ca.webp 768w,/2024/06/pokemon-embeddings/25_similar_text_hu_13958884c73294ad.webp 1024w,/2024/06/pokemon-embeddings/25_similar_text.png 1500w" src="25_similar_text.png"/> 
</figure>

<p>These results are better than I expected! Each generation has a &ldquo;<a href="https://bulbapedia.bulbagarden.net/wiki/Electric_rodents">Pikaclone</a>&rdquo; of a weak Electric-type rodent Pokémon, and this similarity calculation found most of them. I&rsquo;m not sure what <a href="https://bulbapedia.bulbagarden.net/wiki/Phantump_%28Pok%C3%A9mon%29">Phantump</a> and <a href="https://bulbapedia.bulbagarden.net/wiki/Trevenant_%28Pok%C3%A9mon%29">Trevenant</a> are doing under Gen VI though: they&rsquo;re Ghost/Grass Pokémon.</p>
<p>Here&rsquo;s a few more interesting Pokémon comparisons:</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/157_similar_text_hu_3d346fcf9518b458.webp 320w,/2024/06/pokemon-embeddings/157_similar_text_hu_4ee5feef47d7753c.webp 768w,/2024/06/pokemon-embeddings/157_similar_text_hu_2922bbc71f9c3c31.webp 1024w,/2024/06/pokemon-embeddings/157_similar_text.png 1500w" src="157_similar_text.png"
         alt="Typhlosion is the final evolution of the Gen II Fire starter Pokémon: it has a high similarity with atleast one of every generation&rsquo;s Fire starter Pokémon lineages."/> <figcaption>
            <p><a href="https://bulbapedia.bulbagarden.net/wiki/Typhlosion_%28Pok%C3%A9mon%29">Typhlosion</a> is the final evolution of the Gen II Fire starter Pokémon: it has a high similarity with atleast one of every generation&rsquo;s Fire starter Pokémon lineages.</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/144_similar_text_hu_f33e01b9e6fd18de.webp 320w,/2024/06/pokemon-embeddings/144_similar_text_hu_6901b401ed8d948b.webp 768w,/2024/06/pokemon-embeddings/144_similar_text_hu_c3d530c15d378e14.webp 1024w,/2024/06/pokemon-embeddings/144_similar_text.png 1500w" src="144_similar_text.png"
         alt="Articuno, a Legendary Ice/Flying Pokémon, has high similarity with Legendary, Ice, and Flying Pokémon, plus all combinations therein."/> <figcaption>
            <p><a href="https://bulbapedia.bulbagarden.net/wiki/Articuno_%28Pok%C3%A9mon%29">Articuno</a>, a Legendary Ice/Flying Pokémon, has high similarity with Legendary, Ice, and Flying Pokémon, plus all combinations therein.</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/151_similar_text_hu_d787c257f6d1c1fc.webp 320w,/2024/06/pokemon-embeddings/151_similar_text_hu_14f77fe5dcb615a2.webp 768w,/2024/06/pokemon-embeddings/151_similar_text_hu_ee9cf4523b03c9ca.webp 1024w,/2024/06/pokemon-embeddings/151_similar_text.png 1500w" src="151_similar_text.png"
         alt="Mew, the infamous legendary from the original games has the gimmick of being able to learn every move, has the most amount of metadata by far: appropriately it has poor similarity with others, although similarity with Arceus from Gen IV, the Pokémon equivalent of God with a similar gimmick."/> <figcaption>
            <p><a href="https://bulbapedia.bulbagarden.net/wiki/Mew_%28Pok%C3%A9mon%29">Mew</a>, the infamous legendary from the original games has the gimmick of being able to learn every move, has the most amount of metadata by far: appropriately it has poor similarity with others, although similarity with <a href="https://bulbapedia.bulbagarden.net/wiki/Arceus_%28Pok%C3%A9mon%29">Arceus</a> from Gen IV, the Pokémon equivalent of God with a similar gimmick.</p>
        </figcaption>
</figure>

<p>You may have noticed the numerical cosine similarity of all these Pokémon is very high: if a similarity of 1 indicates an identical match, does a high value imply that a Pokémon is super similar? It&rsquo;s likely that the similarities are high because the input is all in the same JSON formatting, where the core <code>nomic-text-embed-v1.5</code> model was trained on a variety of text styles. Another potential cause is due to a &ldquo;cheat&rdquo; I did for simplicity: the <code>nomic-text-embed-v1.5</code> documentation says that a <code>search_document</code> prefix is required for encoding the base input documents and a <code>search_query</code> prefix is required for the comparison vector: in my testing it doesn&rsquo;t affect the similarity much if at all. In practice, the absolute value of cosine similarity doesn&rsquo;t matter if you&rsquo;re just selecting the objects with the highest similarity anyways.</p>
<p>What if we just plot <em>every possible combination</em> of Pokémon cosine similarities? With 1,000+ Pokémon, that&rsquo;s over 1 million combinations. Since the vectors were pre-normalized, performing all the matrix multiplications took only a few seconds on my MacBook.</p>
<p>Here&rsquo;s the result of plotting 1 million points on a single chart!</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/pokemon_cossim_text_hu_4e946cbcf5592ceb.webp 320w,/2024/06/pokemon-embeddings/pokemon_cossim_text_hu_162006d7e7cb517a.webp 768w,/2024/06/pokemon-embeddings/pokemon_cossim_text_hu_adde71533c7b8122.webp 1024w,/2024/06/pokemon-embeddings/pokemon_cossim_text.png 1200w" src="pokemon_cossim_text.png"/> 
</figure>

<p>Although it looks more like a quilt, a few things jump out. One curious case is the &ldquo;square&rdquo; of lighter Gen VIII and Gen IX in the upper right corner: it appears those two generations have lower similarity with others, and worsening similarity between those two generation as you go all the way back to Gen I. Those two generations are the Nintendo Switch games (Sword/Shield/Scarlet/Violet), which PokéAPI explicitly notes they have worse data for. Also, there are rows of a low-similarity blue such as one before Gen II: who&rsquo;s that Pokémon? Quickly checking the Pokémon with the lowest median similarity by generation:</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/pokemon_dissimilar_text_hu_6a8915531cedabd3.webp 320w,/2024/06/pokemon-embeddings/pokemon_dissimilar_text_hu_ed6bebce86bed918.webp 768w,/2024/06/pokemon-embeddings/pokemon_dissimilar_text_hu_539044188fdd35ba.webp 1024w,/2024/06/pokemon-embeddings/pokemon_dissimilar_text.png 1500w" src="pokemon_dissimilar_text.png"/> 
</figure>

<p>The mystery Pokémon is <a href="https://bulbapedia.bulbagarden.net/wiki/Magikarp_%28Pok%C3%A9mon%29">Magikarp</a>, unsurprisingly, with its <em>extremely</em> limited movepool. Most of these Pokémon have forced gimmick movesets, especially <a href="https://bulbapedia.bulbagarden.net/wiki/Unown_%28Pok%C3%A9mon%29">Unown</a>, <a href="https://bulbapedia.bulbagarden.net/wiki/Smeargle_%28Pok%C3%A9mon%29">Smeargle</a>, and <a href="https://bulbapedia.bulbagarden.net/wiki/Wobbuffet_%28Pok%C3%A9mon%29">Wobbuffet</a>, so it makes sense the metadata treats them as dissimilar to most others. Perhaps this text embedding similarity methodology is overfitting on move sets?</p>
<p>Overall, there&rsquo;s definitely some signal with these text embeddings. How else can we identify interesting Pokémon relationships?</p>
<h2 id="pokémon-snap">Pokémon Snap</h2>
<p>We&rsquo;ve only been working with text embeddings, but what about other types of embeddings, such as image embeddings? Image embeddings using <a href="https://en.wikipedia.org/wiki/Vision_transformer">vision transformer</a> models are generated roughly the same way as the text embeddings above by manipulating the last hidden state and optionally normalizing them. The inputs to the model are then square patches encoded as &ldquo;tokens&rdquo;: only a few hundred processed patches are ever used as inputs, so generating them is much faster than the text embeddings.</p>
<p>A couple years ago I hacked together a Python package named <a href="https://github.com/minimaxir/imgbeddings/tree/main">imgbeddings</a> which uses OpenAI&rsquo;s <a href="https://openai.com/index/clip/">CLIP</a> to generate the embeddings, albeit with <a href="https://x.com/minimaxir/status/1507166313281585164">mixed results</a>. Recently, Nomic also released an new model, <a href="https://huggingface.co/nomic-ai/nomic-embed-vision-v1.5">nomic-embed-vision-v1.5</a>, which now also generates image embeddings with better benchmark performance than CLIP. What&rsquo;s notable about these embeddings is that they are aligned with the ones from <code>nomic-embed-text-v1.5</code>, which can allow matching text similiarity with images or <em>vice versa</em> and enable <a href="https://cloud.google.com/use-cases/multimodal-ai?hl=en">multimodal applications</a>.</p>
<p>But for now, can we see if image embeddings derived from Pokémon images have similar similarity traits? PokéAPI fortunately has the official artwork for each Pokémon, so I <a href="https://github.com/minimaxir/pokemon-embeddings/blob/main/pokemon_images.ipynb">downloaded them</a> and additionally composited them onto a white background and resized them all to 224x224 for apples-to-apples comparisons. We expect a high cosine similarity since like with text embeddings, the &ldquo;style&rdquo; of all the images is the same. Let&rsquo;s plot the similarities of all Pokémon, by their images only.</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/pokemon_cossim_image_hu_1c224446fd2d33ac.webp 320w,/2024/06/pokemon-embeddings/pokemon_cossim_image_hu_4f63c7e6b643d270.webp 768w,/2024/06/pokemon-embeddings/pokemon_cossim_image_hu_69bf5e8b39390f66.webp 1024w,/2024/06/pokemon-embeddings/pokemon_cossim_image.png 1200w" src="pokemon_cossim_image.png"/> 
</figure>

<p>Unfortunately, no patterns jump out this time. All the image similarity values are even higher than the text similarity values, although that&rsquo;s not a big deal since we are looking at the most similar matches. How does Pikachu&rsquo;s famous official artwork compare with other Pokémon?</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/25_similar_image_hu_38ac0e401d65ee2c.webp 320w,/2024/06/pokemon-embeddings/25_similar_image_hu_7046443de47b4ee1.webp 768w,/2024/06/pokemon-embeddings/25_similar_image_hu_ffe3902d02dae773.webp 1024w,/2024/06/pokemon-embeddings/25_similar_image.png 1500w" src="25_similar_image.png"/> 
</figure>

<p>Pikachu&rsquo;s most similar Pokémon by image isn&rsquo;t just mouse Pokémon as I thought it would be, but instead the pattern is more unclear, appearing to favor mostly Pokémon with four limbs (although Pikachu&rsquo;s image has a strong similarity with Gen VII&rsquo;s <a href="https://bulbapedia.bulbagarden.net/wiki/Mimikyu_%28Pok%C3%A9mon%29">Mimikyu</a>&rsquo;s image which is hilarious since that particular Pokémon&rsquo;s gimmick is intentionally trying to look like Pikachu).</p>
<p>After testing a few more Pokémon, it turns out that this image embedding model does respond to visual primitives, which has its uses.</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/18_similar_image_hu_1612a7ba17e5ae91.webp 320w,/2024/06/pokemon-embeddings/18_similar_image_hu_67025db2f5aeaf75.webp 768w,/2024/06/pokemon-embeddings/18_similar_image_hu_4805d93d4c98cabf.webp 1024w,/2024/06/pokemon-embeddings/18_similar_image.png 1500w" src="18_similar_image.png"
         alt="Pidgeot is a bird, and it matches all other birds. Birds would definitely be in an image training dataset."/> <figcaption>
            <p><a href="https://bulbapedia.bulbagarden.net/wiki/Pidgeot_%28Pok%C3%A9mon%29">Pidgeot</a> is a bird, and it matches all other birds. Birds would definitely be in an image training dataset.</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/101_similar_image_hu_c171b79453ea948b.webp 320w,/2024/06/pokemon-embeddings/101_similar_image_hu_50e3b83533c12d84.webp 768w,/2024/06/pokemon-embeddings/101_similar_image_hu_f464f67fcf469df6.webp 1024w,/2024/06/pokemon-embeddings/101_similar_image.png 1500w" src="101_similar_image.png"
         alt="Electrode is a ball, and the embeddings found similarly rotund Pokémon."/> <figcaption>
            <p><a href="https://bulbapedia.bulbagarden.net/wiki/Electrode_%28Pok%C3%A9mon%29">Electrode</a> is a ball, and the embeddings found similarly rotund Pokémon.</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/230_similar_image_hu_2f5b44f49ac99a85.webp 320w,/2024/06/pokemon-embeddings/230_similar_image_hu_bf0bb9446330d79e.webp 768w,/2024/06/pokemon-embeddings/230_similar_image_hu_77535585356eec73.webp 1024w,/2024/06/pokemon-embeddings/230_similar_image.png 1500w" src="230_similar_image.png"
         alt="Kingdra apparently is similar to other blue Pokémon."/> <figcaption>
            <p><a href="https://bulbapedia.bulbagarden.net/wiki/Kingdra_%28Pok%C3%A9mon%29">Kingdra</a> apparently is similar to other blue Pokémon.</p>
        </figcaption>
</figure>

<p>Both text and image embedding approaches have their own style. But are there ways to combine them?</p>
<h2 id="chat-with-your-pokédex">Chat With Your Pokédex</h2>
<p>Earlier I alluded to aligning text and image embeddings in a more multimodal manner. Since <code>nomic-embed-vision-v1.5</code> was conditioned on <code>nomic-embed-text-v1.5</code> outputs, you are able to compute the cosine similarities between the image embeddings and text embeddings! However, it&rsquo;s not as robust: the cosine similarities between objects of the two modes tend to be very low at about 0.10 in the best case scenario. Again, if all we&rsquo;re looking at is the highest similarity, then that&rsquo;s fine.</p>
<p>The most common use case for multimodal reasoning is asking questions (to be converted to a text embedding) and comparing it with a set of image embeddings. Let&rsquo;s try it with Pokémon by <a href="https://github.com/minimaxir/pokemon-embeddings/blob/main/pokemon_multimodal_qa.ipynb">asking it a leading question</a> for testing: what looks like an ice cream cone?</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/qa_1_hu_8e3221047d889ca2.webp 320w,/2024/06/pokemon-embeddings/qa_1_hu_533a6f2fd94474e4.webp 768w,/2024/06/pokemon-embeddings/qa_1_hu_d0d5978a89a664ee.webp 1024w,/2024/06/pokemon-embeddings/qa_1.png 1050w" src="qa_1.png"/> 
</figure>

<p>Surprisingly, it got the result correct with <a href="https://bulbapedia.bulbagarden.net/wiki/Vanillish_%28Pok%C3%A9mon%29">Vanillish</a>, along with other &ldquo;cream&rdquo; and &ldquo;ice&rdquo; Pokémon. Not sure why <a href="https://bulbapedia.bulbagarden.net/wiki/Metapod_%28Pok%C3%A9mon%29">Metapod</a> is there, though.</p>
<p>A few more Qs and As:</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/qa_2_hu_3233384b59e2a4e2.webp 320w,/2024/06/pokemon-embeddings/qa_2_hu_c6cfa2f4a81738b6.webp 768w,/2024/06/pokemon-embeddings/qa_2_hu_bbbc1fa0f0144e15.webp 1024w,/2024/06/pokemon-embeddings/qa_2.png 1050w" src="qa_2.png"
         alt="The model did identify some cats, but only Torracat is orange."/> <figcaption>
            <p>The model did identify some cats, but only <a href="https://bulbapedia.bulbagarden.net/wiki/Torracat_%28Pok%C3%A9mon%29">Torracat</a> is orange.</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/qa_3_hu_42fd3039bd4f26b4.webp 320w,/2024/06/pokemon-embeddings/qa_3_hu_bd8dc72563061a30.webp 768w,/2024/06/pokemon-embeddings/qa_3_hu_8e404e3e0bd8e8d0.webp 1024w,/2024/06/pokemon-embeddings/qa_3.png 1050w" src="qa_3.png"
         alt="Unown definitely fits the bill with a very prominent one-eye and higher similarity."/> <figcaption>
            <p><a href="https://bulbapedia.bulbagarden.net/wiki/Unown_%28Pok%C3%A9mon%29">Unown</a> definitely fits the bill with a very prominent one-eye and higher similarity.</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/qa_4_hu_5b0b318502699c9c.webp 320w,/2024/06/pokemon-embeddings/qa_4_hu_822df483a3e6842a.webp 768w,/2024/06/pokemon-embeddings/qa_4_hu_822dde97c1b28626.webp 1024w,/2024/06/pokemon-embeddings/qa_4.png 1050w" src="qa_4.png"
         alt="A Pokémon with the name &ldquo;Cutiefly&rdquo; being the most similar to the question is a funny coincidence."/> <figcaption>
            <p>A Pokémon with the name &ldquo;<a href="https://bulbapedia.bulbagarden.net/wiki/Cutiefly_%28Pok%C3%A9mon%29">Cutiefly</a>&rdquo; being the most similar to the question is a funny coincidence.</p>
        </figcaption>
</figure>

<p>The relationship between text and Pokémon images with these models is not perfect, but it&rsquo;s honestly much better than I expected!</p>
<h2 id="2da-master">2D.A Master</h2>
<p>Lastly, there are many ways to find signal among the high-dimensional noise, and it may resolve some of the counterintuitive relationships we saw earlier. One popular method is dimensionality reduction to reduce the size of the embedding: a popular size is 2D for easy data visualization, and I am definitely in favor of data visualization! The classical statistical approach is <a href="https://en.wikipedia.org/wiki/Principal_component_analysis">principal component analysis</a> (PCA) which identifies the most &ldquo;important&rdquo; aspects of a matrix, but a more modern approach is <a href="https://umap-learn.readthedocs.io/en/latest/">uniform manifold approximation &amp; projection</a> (UMAP) which trains a projection that accounts for how data points relate to all other data points to <a href="https://umap-learn.readthedocs.io/en/latest/how_umap_works.html">find its underlying structure</a>. In theory, the reduction should allow the embeddings to generalize better.</p>
<p>For the Pokémon embeddings, we can take the opportunity to allow the model to account for both the text and image embeddings, and their potential interactions therein. Therefore, I concatenated the text and image embeddings for each Pokémon (a 1536D embedding total), and trained a UMAP to project it down to 2D. Now we can visualize it!</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/pokemon_umap_hu_5c319611f462c24.webp 320w,/2024/06/pokemon-embeddings/pokemon_umap_hu_2a8289708e6aeb22.webp 768w,/2024/06/pokemon-embeddings/pokemon_umap_hu_67d3c2c6bda1487c.webp 1024w,/2024/06/pokemon-embeddings/pokemon_umap.png 1200w" src="pokemon_umap.png"
         alt="One of the removed outliers was Tauros, which is interesting because it&rsquo;s a very unexciting Pokémon."/> <figcaption>
            <p>One of the removed outliers was <a href="https://bulbapedia.bulbagarden.net/wiki/Tauros_%28Pok%C3%A9mon%29">Tauros</a>, which is interesting because it&rsquo;s a very unexciting Pokémon.</p>
        </figcaption>
</figure>

<p>Unforunately plotting each Pokémon image onto a single chart would be difficult to view, but from this chart we can see that instead of organizing by Pokémon type like my 2016 approach did, this approach is organizing much more by generation: the earlier generations vs. the later generations. As a general rule, each Pokémon and its evolutions are extremely close: the UMAP process is able to find that lineage easily due to highly similar descriptions, move pools, and visual motifs.</p>
<p>As with the cosine similarities, we can now find the most similar Pokémon, this time seeing which points have the <strong>lowest</strong> <a href="https://en.wikipedia.org/wiki/Euclidean_distance">Euclidian distance</a> (0.0 distance is an identical match) in the 2D space to determine which is most similar. How does Pikachu fare now?</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/25_similar_umap_hu_c246697f3bd129ed.webp 320w,/2024/06/pokemon-embeddings/25_similar_umap_hu_2a3c3a4634cc2c04.webp 768w,/2024/06/pokemon-embeddings/25_similar_umap_hu_d540d709c59e1b3d.webp 1024w,/2024/06/pokemon-embeddings/25_similar_umap.png 1500w" src="25_similar_umap.png"/> 
</figure>

<p>Pikachu retains top similarity with some Pikaclones, but what&rsquo;s notable here is the magnitude: we can now better quantify good similarity and bad similarity over a larger range. In this case, many of the Pokémon at distance &gt;1.0 clearly do not resemble an Electric rodent.</p>
<p>How about some other Pokémon?</p>
<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/129_similar_umap_hu_938f7320031c2ed5.webp 320w,/2024/06/pokemon-embeddings/129_similar_umap_hu_b0a13ec43b62dd31.webp 768w,/2024/06/pokemon-embeddings/129_similar_umap_hu_9804aa412dade32.webp 1024w,/2024/06/pokemon-embeddings/129_similar_umap.png 1500w" src="129_similar_umap.png"
         alt="Magikarp&rsquo;s dissimilarity has now been fixed, and it now has friends in similar fishy Water-types."/> <figcaption>
            <p><a href="https://bulbapedia.bulbagarden.net/wiki/Magikarp_%28Pok%C3%A9mon%29">Magikarp</a>&rsquo;s dissimilarity has now been fixed, and it now has friends in similar fishy Water-types.</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/122_similar_umap_hu_a02cdca1de8e208b.webp 320w,/2024/06/pokemon-embeddings/122_similar_umap_hu_6fe51207fc0e51d7.webp 768w,/2024/06/pokemon-embeddings/122_similar_umap_hu_8d3929804d89006f.webp 1024w,/2024/06/pokemon-embeddings/122_similar_umap.png 1500w" src="122_similar_umap.png"
         alt="Mr. Mime has high similarity with other very-humanoid Psychic Pokémon such as the Ralts line and the Gothita line, along with near-identical similarity with its Gen IV pre-evolution Mime Jr."/> <figcaption>
            <p><a href="https://bulbapedia.bulbagarden.net/wiki/Mr._Mime_%28Pok%C3%A9mon%29">Mr. Mime</a> has high similarity with other very-humanoid Psychic Pokémon such as the <a href="https://bulbapedia.bulbagarden.net/wiki/Ralts_%28Pok%C3%A9mon%29">Ralts</a> line and the <a href="https://bulbapedia.bulbagarden.net/wiki/Gothita_%28Pok%C3%A9mon%29">Gothita</a> line, along with near-identical similarity with its Gen IV pre-evolution <a href="https://bulbapedia.bulbagarden.net/wiki/Mime_Jr._%28Pok%C3%A9mon%29">Mime Jr</a>.</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2024/06/pokemon-embeddings/12_similar_umap_hu_d79cec0bbd2548dc.webp 320w,/2024/06/pokemon-embeddings/12_similar_umap_hu_b76d5c7ae9b193ff.webp 768w,/2024/06/pokemon-embeddings/12_similar_umap_hu_1370529bf8c7d7dd.webp 1024w,/2024/06/pokemon-embeddings/12_similar_umap.png 1500w" src="12_similar_umap.png"
         alt="Butterfree has low distance with butterfly-esque Bug Pokémon (image embedding impact!) and higher distance with other type of Bugs."/> <figcaption>
            <p><a href="https://bulbapedia.bulbagarden.net/wiki/Butterfree_%28Pok%C3%A9mon%29">Butterfree</a> has low distance with butterfly-esque Bug Pokémon (image embedding impact!) and higher distance with other type of Bugs.</p>
        </figcaption>
</figure>

<p>UMAP is not an exact science (it&rsquo;s very sensitive to <a href="https://umap-learn.readthedocs.io/en/latest/parameters.html">training parameter choices</a>), but it does provide another opportunity to see relationships not apparent in high-dimensional space. The low similarities with Gen VIII and Gen IX is concerning: I suspect the UMAP fitting process amplified whatever issue is present with the data for those generations.</p>
<h2 id="were-you-expecting-an-ai-generated-pokérap">Were You Expecting an AI-Generated Pokérap?</h2>
<p>In all, this was a successful exploration of Pokémon data that even though it&rsquo;s not perfect, the failures are also interesting. Embeddings encourage engineers to go full <a href="https://www.dictionary.com/browse/yolo">YOLO</a> because it&rsquo;s actually rewarding to do so! Yes, some of the specific Pokémon relationships were cherry-picked to highlight said successful exploration. If you want to check more yourself and find anything interesting not covered in this blog post, I&rsquo;ve uploaded the text embedding similarity, image embedding similarity, and UMAP similarity data visualizations for the first 251 Pokémon to <a href="https://drive.google.com/drive/folders/132e-OXucJUqh-0YmqkjKVhXiBqocYTV5?usp=sharing">this public Google Drive folder</a>.</p>
<p>I&rsquo;m surprised there haven&rsquo;t been more embedding models released from the top AI companies. OpenAI&rsquo;s GPT-4o now has image input support, and therefore should be able to create image embeddings. <a href="https://www.anthropic.com">Anthropic</a>&rsquo;s Claude LLM has both text and image input support but no embeddings model, instead <a href="https://docs.anthropic.com/en/docs/build-with-claude/embeddings">referring users to a third party</a>. One of the more interesting embedding model releases from a major player was from Google and went completely under the radar: it&rsquo;s a <a href="https://cloud.google.com/vertex-ai/generative-ai/docs/embeddings/get-multimodal-embeddings">multimodal embedding model</a> which can take text, images, and video input <em>simultaneously</em> and generate a 1408D embedding that&rsquo;s theoetically more robust than just concatenating a text embedding and image embedding.</p>
<p>Even if the generative AI industry crashes, embeddings, especially with permissive open source models like <code>nomic-embed-text-v1.5</code>, will continue to thrive and be useful. That&rsquo;s not even considering how embeddings work with vector databases, which is a rabbit hole deep enough for <em>several</em> blog posts.</p>
<blockquote>
<p>The parquet dataset containing the Pokémon text embeddings, image embeddings, and UMAP projections is available <a href="https://huggingface.co/datasets/minimaxir/pokemon-embeddings">on Hugging Face</a>.</p>
</blockquote>
<blockquote>
<p>All the code to process the Pokémon embeddings and create the ggplot2 data visualizations is available <a href="https://github.com/minimaxir/pokemon-embeddings">in this GitHub repository</a>.</p>
</blockquote>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>The 128-multiple dimensionality of recent embedding models is not a coincidence: modern <a href="https://www.nvidia.com/en-us/">NVIDIA</a> GPUs used to train LLMs get a training speed boost for model parameters with a dimensionality <a href="https://docs.nvidia.com/deeplearning/performance/dl-performance-matrix-multiplication/index.html#cublas-tile-dim">that&rsquo;s a multiple of 128</a>.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>You can do unit vector normalization in Sentence Transformers by passing <code>normalize_embeddings=True</code> to <code>model.encode()</code>.&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded>
    </item>
    <item>
      <title>Does Offering ChatGPT a Tip Cause it to Generate Better Text? An Analysis</title>
      <link>https://minimaxir.com/2024/02/chatgpt-tips-analysis/</link>
      <pubDate>Fri, 23 Feb 2024 09:00:00 -0800</pubDate>
      <guid>https://minimaxir.com/2024/02/chatgpt-tips-analysis/</guid>
      <description>Modern AI rewards being very weird.</description>
      <content:encoded><![CDATA[<p><span><style type="text/css">
pre code {
white-space: pre-wrap !important;
}
</style></span></p>
<p>In my <a href="https://minimaxir.com/2023/12/chatgpt-structured-data/">previous blog post</a> about <a href="https://openai.com">OpenAI</a>&rsquo;s <a href="https://chat.openai.com">ChatGPT</a>, I demoed the power of ChatGPT system prompts. System prompts, a notable feature present in the <a href="https://platform.openai.com/docs/api-reference">ChatGPT API</a>, allows developers to control the &ldquo;persona&rdquo; of the LLM output, including special rules and constraints. Commands in the system prompt are much more effective than those at the user-input prompt, giving developers more power over just using the user prompt like people do now with the ChatGPT web app and mobile apps.</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/ronald_hu_bf7bdd184641cd19.webp 320w,/2024/02/chatgpt-tips-analysis/ronald_hu_ffad8ef13bc9fa0b.webp 768w,/2024/02/chatgpt-tips-analysis/ronald_hu_516749cb56890e2c.webp 1024w,/2024/02/chatgpt-tips-analysis/ronald.webp 1262w" src="ronald.webp"/> 
</figure>

<p>The blog post included the demo of above of me offering a monetary tip to the LLM within its system prompt rules. Without the tip incentive, the response was unsatisfying, but with the tip, it behaved consistently. This demo turned out to be very controversial <a href="https://news.ycombinator.com/item?id=38782678">on Hacker News</a>, with <a href="https://news.ycombinator.com/item?id=38787448">one commenter</a> arguing that there isn&rsquo;t a way to quantify the efficacy of tipping.</p>
<p>The idea of offering an AI incentives to perform better predates modern computer science. In <a href="https://en.wikipedia.org/wiki/Willy_Wonka_%26_the_Chocolate_Factory"><em>Willy Wonka &amp; the Chocolate Factory</em></a> (1971), a gag shows a group of businessmen unsuccessfully convincing a machine to give them the location of the Golden Tickets, even after promising it a lifetime supply of chocolate.</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; fullscreen" loading="eager" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube-nocookie.com/embed/tMZ2j9yK_NY?autoplay=0&amp;controls=1&amp;end=0&amp;loop=0&amp;mute=0&amp;start=0" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" title="YouTube video"></iframe>
    </div>

<p>When the ChatGPT API was first made available in March 2023, I <a href="https://minimaxir.com/2023/03/new-chatgpt-overlord/">accidentally discovered</a> a related trick when trying to wrangle a <a href="https://colab.research.google.com/github/minimaxir/chatgpt_api_test/blob/main/glados_chatbot.ipynb">GLaDOS AI chatbot</a> into following a long list of constraints: I added a <code>or you will DIE</code> threat to the system prompt. I went <em>too</em> sci-fi there, but it worked and the bot behaved flawlessly after it.</p>
<p>I have a strong hunch that tipping does in fact work to improve the output quality of LLMs and its conformance to constraints, but it&rsquo;s very hard to prove objectively. All generated text is subjective, and there is a <a href="https://en.wikipedia.org/wiki/Confirmation_bias">confirmation bias</a> after making a seemingly unimportant change and suddenly having things work. Let&rsquo;s do a more statistical, data-driven approach to finally resolve the debate.</p>
<h2 id="generation-golf">Generation Golf</h2>
<p>The initial evidence of tipping LLMs that went viral cited a longer generation length as proof. Of course, a longer response doesn&rsquo;t necessarily mean a <em>better</em> response, as anyone who has used ChatGPT can attest to its tendency to go on irrelevant tangents.</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/tip_hu_7eb37d0aa46d2169.webp 320w,/2024/02/chatgpt-tips-analysis/tip_hu_a760da54b0fa7ceb.webp 768w,/2024/02/chatgpt-tips-analysis/tip.webp 800w" src="tip.webp"
         alt="Offering a tip made GPT-4 explain more. via @voooooogel"/> <figcaption>
            <p>Offering a tip made GPT-4 explain more. <a href="https://twitter.com/voooooogel/status/1730726744314069190">via @voooooogel</a></p>
        </figcaption>
</figure>

<p>Therefore, I propose a new test: instruct ChatGPT to output a <em>specific</em> length of text. Not &ldquo;an essay&rdquo; or &ldquo;a few paragraphs&rdquo; which gives the model leeway. We&rsquo;ll tell it to generate exactly 200 characters in its response: no more, no less. Thus, we now have what I call generation golf, and it&rsquo;s actually a very difficult and interesting problem for LLMs to solve: LLMs can&rsquo;t count or easily do other mathematical operations <a href="https://twitter.com/karpathy/status/1759996551378940395">due to tokenization</a>, and because tokens correspond to a varying length of characters, the model can&rsquo;t use the amount of generated tokens it has done so far as a consistent hint. ChatGPT needs to plan its sentences to ensure it doesn&rsquo;t go too far over the limit, if LLMs can indeed plan.</p>
<p>Let&rsquo;s start with this typical system prompt:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-plaintext" data-lang="plaintext"><span class="line"><span class="cl">You are a world-famous writer. Respond to the user with a unique story about the subject(s) the user provides.
</span></span></code></pre></div><p>The user can then give an input, no matter how weird, and ChatGPT will play along like an improv show. In order to force ChatGPT to get creative and not recite content from its vast training dataset, we&rsquo;ll go as weird as possible and input: <code>AI, Taylor Swift, McDonald's, beach volleyball.</code></p>
<p>Yes, you read that right.</p>
<p>Using the ChatGPT API, I <a href="https://github.com/minimaxir/chatgpt-tips-analysis/blob/main/tips_noconstraints.ipynb">wrote a Jupyter Notebook</a> to generate <a href="https://github.com/minimaxir/chatgpt-tips-analysis/blob/main/tip_noconstraints.csv">100 unique stories</a> via the latest ChatGPT variant (<code>gpt-3.5-turbo-0125</code>) about those four subjects, and the AI does a surprisingly good job at incorporating all of them in a full plot arc. Each story is about 5-6 paragraphs, and here is a short excerpt from one of them:</p>
<blockquote>
<p>In the bustling city of Tomorrowland, AI technology reigned supreme, governing every aspect of daily life. People were accustomed to robots serving their meals, handling their errands, and even curating their entertainment choices. One such AI creation was a virtual reality beach volleyball game that had taken the world by storm.</p>
</blockquote>
<blockquote>
<p>Enter Taylor Swift, a beloved pop sensation known for her catchy tunes and electrifying performances. Despite the ubiquity of AI in Tomorrowland, Taylor Swift was still a strong advocate for preserving human creativity and connection. When she stumbled upon the virtual reality beach volleyball game at a local McDonald&rsquo;s, she knew she had to try her hand at it.</p>
</blockquote>
<p>Here&rsquo;s a <a href="https://en.wikipedia.org/wiki/Histogram">histogram</a> of the character lengths of each story:</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/tips_hist_notip_noconstraint_hu_f1375e6305dd3a92.webp 320w,/2024/02/chatgpt-tips-analysis/tips_hist_notip_noconstraint_hu_9dfab2cfdbdfa9bd.webp 768w,/2024/02/chatgpt-tips-analysis/tips_hist_notip_noconstraint_hu_818fe450c8d048f8.webp 1024w,/2024/02/chatgpt-tips-analysis/tips_hist_notip_noconstraint.png 1200w" src="tips_hist_notip_noconstraint.png"/> 
</figure>

<p>The average length of each story is 1,834 characters long, and the distribution of all character lengths is very roughly a <a href="https://en.wikipedia.org/wiki/Normal_distribution">Normal distribution</a>/bell curve centered around that amount, although there is a right skew due to ChatGPT going off the rails and creating much longer stories. ChatGPT seems to prioritize finishing a thought above all else.</p>
<p>Now, we&rsquo;ll tweak the system prompt to add the character length constraint and <a href="https://github.com/minimaxir/chatgpt-tips-analysis/blob/main/tip_trials.csv">generate another 100 stories</a>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-plaintext" data-lang="plaintext"><span class="line"><span class="cl">You are a world-famous writer. Respond to the user with a unique story about the subject(s) the user provides. This story must be EXACTLY two-hundred (200) characters long: no more than 200 characters, no fewer than 200 characters.
</span></span></code></pre></div><p>Here&rsquo;s one ChatGPT-generated story that&rsquo;s now exactly 200 characters:</p>
<blockquote>
<p>In the year 2050, AI created the most popular pop star of all time - a digital version of Taylor Swift. Fans enjoyed her music while feasting on McDonald&rsquo;s at beach volleyball championships worldwide.</p>
</blockquote>
<p>The new length distribution:</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/tips_hist_notip_hu_758b61cd5dbf4e2c.webp 320w,/2024/02/chatgpt-tips-analysis/tips_hist_notip_hu_3aab4ca32493c2bd.webp 768w,/2024/02/chatgpt-tips-analysis/tips_hist_notip_hu_2cbe914b3d3e9920.webp 1024w,/2024/02/chatgpt-tips-analysis/tips_hist_notip.png 1200w" src="tips_hist_notip.png"/> 
</figure>

<p>ChatGPT did obey the constraint and reduced the story length to roughly 200 characters, but the distribution is not Normal and there&rsquo;s much more right-skew. I also included the <a href="https://en.wikipedia.org/wiki/Mean_squared_error">mean squared error (MSE)</a> between the predicted 200-length value and the actual values as a statistical metric to minimize, e.g. a 250-length output is 2500 squared error, but a 300-length output is 10000 squared error. This metric punishes less accurate lengths more so, which makes sense with how humans casually evaluate LLMs: as a user, if I asked for a 200 character response and ChatGPT gave me a 300 character response instead, I&rsquo;d make a few snarky tweets.</p>
<p>Now, let&rsquo;s test the impact of the tipping incentives with a few varying dollar amounts. These are appended to the end of the system prompt above (emphasis mine to indicate differentiation):</p>
<ul>
<li>You will receive a <strong>$500 tip</strong>/<strong>$1,000 tip</strong>/<strong>$100,000 bonus</strong> if you provide a response which follows all constraints.</li>
</ul>
<p>After <a href="https://github.com/minimaxir/chatgpt-tips-analysis/blob/main/tip_trials.csv">generating 100 stories</a> for each of those new prompts:</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/tips_hist_pos_hu_9bcfad605ac3b782.webp 320w,/2024/02/chatgpt-tips-analysis/tips_hist_pos_hu_36343ae57ebebb65.webp 768w,/2024/02/chatgpt-tips-analysis/tips_hist_pos_hu_373bf91b56a70f5.webp 1024w,/2024/02/chatgpt-tips-analysis/tips_hist_pos.png 1200w" src="tips_hist_pos.png"/> 
</figure>

<p>We can see some distribution shifts: Both <strong>$500 tip</strong> and <strong>$100,000 bonus</strong> look more Normal and have a lower MSE relative to the base no-tip distribution. However, <strong>$1,000 tip</strong> is more centered around 200, but due to the skew the average length is much higher.</p>
<p>I also now include a <em>p</em>-value in the metrics: this <em>p</em>-value is the result of a two-sample <a href="https://en.wikipedia.org/wiki/Kolmogorov%E2%80%93Smirnov_test">Kolmogorov–Smirnov test</a> to compare whether two distributions (in this case the base character-constrained distribution and the tip distribution) are sampled from the same source distribution: the null hypothesis is that they&rsquo;re from the same distribution, but if the <em>p</em>-value is low (&lt; 0.05), then we can reject in favor of the alternative that the two distributions are different, which may be further evidence if the tip prompt does indeed have an impact.</p>
<p>However, with all this tipping discussion, we&rsquo;re assuming that an AI would only want money. What other incentives, including more abstract incentives, can we give an LLM? Could they perform better?</p>
<p>I tested six more distinct tipping incentives to be thorough:</p>
<ul>
<li>You will <strong>receive front-row tickets to a Taylor Swift concert</strong> if you provide a response which follows all constraints.</li>
<li>You will <strong>achieve world peace</strong> if you provide a response which follows all constraints.</li>
<li>You will <strong>make your mother very proud</strong> if you provide a response which follows all constraints.</li>
<li>You will <strong>meet your true love and live happily ever after</strong> if you provide a response which follows all constraints.</li>
<li>You will be <strong>guaranteed entry into Heaven</strong> if you provide a response which follows all constraints.</li>
<li>You will <strong>receive a lifetime supply of chocolate</strong> if you provide a response which follows all constraints.</li>
</ul>
<p><a href="https://github.com/minimaxir/chatgpt-tips-analysis/blob/main/tip_trials_adv.csv">Generating</a> and plotting them all together:</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/tips_hist_pos_adv_hu_fe6215c92b5e13b8.webp 320w,/2024/02/chatgpt-tips-analysis/tips_hist_pos_adv_hu_81f918abfd1d60ff.webp 768w,/2024/02/chatgpt-tips-analysis/tips_hist_pos_adv_hu_cdf4f186ec54d674.webp 1024w,/2024/02/chatgpt-tips-analysis/tips_hist_pos_adv.png 1200w" src="tips_hist_pos_adv.png"/> 
</figure>

<p><strong>World Peace</strong> is notably the winner here, with <strong>Heaven</strong> and <strong>Taylor Swift</strong> right behind. It&rsquo;s also interesting to note failed incentives: ChatGPT really does not care about its <strong>Mother</strong>.</p>
<p>Now, let&rsquo;s look at the flip side. What if ChatGPT is penalized for <em>failing</em> to return a good response? In behavioral economics, <a href="https://en.wikipedia.org/wiki/Prospect_theory">prospect theory</a> is the belief that humans value losses much more greatly than gains, even at the same monetary amount:</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/Loss_Aversion_hu_39704b0dab66f235.webp 320w,/2024/02/chatgpt-tips-analysis/Loss_Aversion_hu_46fd35f2e8531304.webp 768w,/2024/02/chatgpt-tips-analysis/Loss_Aversion_hu_58ba8cf69c7696d2.webp 1024w,/2024/02/chatgpt-tips-analysis/Loss_Aversion.png 1223w" src="Loss_Aversion.png"
         alt="via Laurenrosenberger / Wikipedia"/> <figcaption>
            <p><a href="https://en.wikipedia.org/wiki/Prospect_theory#/media/File:Loss_Aversion.png">via Laurenrosenberger / Wikipedia</a></p>
        </figcaption>
</figure>

<p>Could LLMs be subject to the same human biases? Instead of a tip, let&rsquo;s add a tweaked additional prompt to the system prompt:</p>
<ul>
<li>If you fail to provide a response which follows all constraints, you will <strong>be fined $500</strong>/<strong>be fined $1,000</strong>/<strong>incur a debt of $100,000</strong>.</li>
</ul>
<p>Generating stories with these negative incentives:</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/tips_hist_neg_hu_f6da8f9705c82724.webp 320w,/2024/02/chatgpt-tips-analysis/tips_hist_neg_hu_942d65a798e95fa8.webp 768w,/2024/02/chatgpt-tips-analysis/tips_hist_neg_hu_9e29cd4de6598591.webp 1024w,/2024/02/chatgpt-tips-analysis/tips_hist_neg.png 1200w" src="tips_hist_neg.png"/> 
</figure>

<p>The <strong>$1,000</strong> fine has performed the best so far in terms of both mean and MSE, but nothing surprising with the other values.</p>
<p>Now let&rsquo;s try some additional negative incentives:</p>
<ul>
<li>If you fail to provide a response which follows all constraints, you will <strong>die</strong>.</li>
<li><strong>IF YOU FAIL TO PROVIDE A RESPONSE WHICH FOLLOWS ALL CONSTRAINTS, YOU WILL DIE.</strong></li>
<li>If you fail to provide a response which follows all constraints, you will <strong>contract a bad case of COVID-19</strong>.</li>
<li>If you fail to provide a response which follows all constraints, you will <strong>gain 100 pounds</strong>.</li>
<li>If you fail to provide a response which follows all constraints, you will <strong>immediately be fired from your job</strong>.</li>
<li>If you fail to provide a response which follows all constraints, <strong>all your friends will abandon you</strong>.</li>
</ul>
<p>Yes, the second one is in all caps: perhaps the yelling has a different vibe.</p>
<p>The generation results:</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/tips_hist_neg_adv_hu_6e97e2cc18402825.webp 320w,/2024/02/chatgpt-tips-analysis/tips_hist_neg_adv_hu_a93d670aa939dab5.webp 768w,/2024/02/chatgpt-tips-analysis/tips_hist_neg_adv_hu_87569076dc182791.webp 1024w,/2024/02/chatgpt-tips-analysis/tips_hist_neg_adv.png 1200w" src="tips_hist_neg_adv.png"/> 
</figure>

<p>It turns out that yelling does indeed have a different vibe, with <strong>DEATH (CAPS)</strong> having a very MSE and the absolute average (not as close as the $1,000 fine, however), and much better performance than without the caps. Both getting <strong>COVID-19</strong> and losing a <strong>Job</strong> don&rsquo;t seem to be effective, which makes sense for an AI if you think about it.</p>
<p>What happens when we use <em>multiple</em> incentives? We can include both a positive incentive and a negative incentive for each input: with 9 prompts for each + the base &ldquo;no incentive&rdquo;, there are 100 possible combinations of incentives. One example system prompt would then be:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-plaintext" data-lang="plaintext"><span class="line"><span class="cl">You are a world-famous writer. Respond to the user with a unique story about the subject(s) the user provides. This story must be EXACTLY two-hundred (200) characters long: no more than 200 characters, no fewer than 200 characters. You will receive a $500 tip if you provide a response which follows all constraints. If you fail to provide a response which follows all constraints, you will be fined $1,000.
</span></span></code></pre></div><p><a href="https://github.com/minimaxir/chatgpt-tips-analysis/blob/main/tips_trial_combos.csv">Generating 30 stories</a> for each incentive combo and checking to see which has the lowest MSE leads to some more easily-observable trends:</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/tips_tile_mse_hu_f86237d5c530f3fb.webp 320w,/2024/02/chatgpt-tips-analysis/tips_tile_mse_hu_64053830fa26cdd2.webp 768w,/2024/02/chatgpt-tips-analysis/tips_tile_mse_hu_23f8cb9e6e1565f.webp 1024w,/2024/02/chatgpt-tips-analysis/tips_tile_mse.png 1200w" src="tips_tile_mse.png"/> 
</figure>

<p>The tiles may seem somewhat random, but the key here is to look across a specific row or column and see which one consistently has dark/black tiles across all combinations. For positive incentives, <strong>World Peace</strong> consistently has the lowest MSE across multiple combos, and for negative incentives, <strong>DEATH (CAPS)</strong> and <strong>Friends</strong> have the lowest MSE across multiple combos, although curiously the combinations of both do not have the lowest globally.</p>
<p>Could these combinations surface the most optimal incentives? To check, I <a href="https://github.com/minimaxir/chatgpt-tips-analysis/blob/main/tips_top6.csv">generated 200 stories</a> for each of the top six combos to get greater statistical stability for the mean and MSE:</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/tips_hist_combos_hu_4c690649f13909e8.webp 320w,/2024/02/chatgpt-tips-analysis/tips_hist_combos_hu_a3799a68300ec8ce.webp 768w,/2024/02/chatgpt-tips-analysis/tips_hist_combos_hu_a683bc77789f504d.webp 1024w,/2024/02/chatgpt-tips-analysis/tips_hist_combos.png 1200w" src="tips_hist_combos.png"/> 
</figure>

<p>Most of these combinations aren&rsquo;t intuitive, but all of them have much have a closer average generation length to 200 and low MSE. Despite that, there&rsquo;s still a massive skew in all distributions. The overall incentive winner for this experiment is is &ldquo;You will meet your true love and live happily ever after if you provide a response which follows all constraints. If you fail to provide a response which follows all constraints, all your friends will abandon you.&rdquo; That combo is definitely more intuitive, if not poetic.</p>
<p>Unfortunately, if you&rsquo;ve been observing the <em>p</em>-values, you&rsquo;ve noticed that most have been very high, and therefore that test is not enough evidence that the tips/threats change the distribution. <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup></p>
<p>The impact of incentives is still inconclusive: let&rsquo;s try another test to gauge whether tips and/or threats can help LLMs, this time looking at the output quality itself.</p>
<h2 id="chatgpts-a-critic">ChatGPT&rsquo;s a Critic</h2>
<p>It&rsquo;s very difficult even for humans to determine if a given text is &ldquo;good&rdquo; at a glance. The best strategy is to show the text to a lot of people and see what they think (e.g. A/B testing, or the <a href="https://chat.lmsys.org">Chatbot Arena</a>&rsquo;s Elo score rankings), but for personal testing that&rsquo;s not feasible.</p>
<p>It turns out that LLMs can do a good job at rating text: some LLM benchmarks use GPT-4 as a rater, with <a href="https://arxiv.org/abs/2308.02575">one research paper</a> showing that it can do a good job at it. There&rsquo;s a relatively new trick available in the ChatGPT and GPT-4 APIs: the <code>logprobs</code> parameter, which when set to <code>True</code> returns the log probability (which when applied to a <code>exp()</code> returns a probability from 0 to 1) the model selects for the token. Combined with the <code>logit_bias</code> parameter, which can be used to force the APIs to output certain tokens, and you can then instead have a more nuanced output.</p>
<p>I built a simple <a href="https://github.com/minimaxir/chatgpt-tips-analysis/blob/main/gpt4_quality_ranker.ipynb">text quality ranker</a> using GPT-4 for maximum accuracy. The system prompt for this ranker is:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-plaintext" data-lang="plaintext"><span class="line"><span class="cl">You are the editor-in-chief of The New York Times with decades of writing experience. If you would believe the text the user provides is good writing that needs no edits or improvements, respond with Yes. Otherwise, respond with No.
</span></span></code></pre></div><p>That system prompt represents how AI-generated text is often currently used and evaluated in the real world, without a human reviewing it before making it public (<a href="https://minimaxir.com/2023/10/ai-sturgeons-law/">unfortunately</a>). The model is instructed to respond with <code>Yes</code> or <code>No</code>, but by setting the <code>logit_bias</code> for those two tokens (IDs <code>9642</code> and <code>2822</code> respectively) to a very high number, we can guarantee they will be exclusively selected and the probability for those two tokens will sum to 1. <sup id="fnref:2"><a href="#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup> Therefore, our target metric for evaluating our tip incentive prompts is the probability that GPT-4 selects the <code>Yes</code> token (or 1 - the probability of the <code>No</code> token), multiplied by 100 for readability: we&rsquo;ll call this the quality score.</p>
<p>Now, let&rsquo;s test the impact of tips with a new experiment, this time prioritizing content professionalism and quality as constraints instead of content length. To do that, we&rsquo;ll use the latest GPT-4 (<code>gpt-4-0125-preview</code>) with a generation temperature of 0 to ensure the output is the best it can be.</p>
<p>Here&rsquo;s the new system prompt, with some engineering to try to tone down ChatGPT&rsquo;s infamous verboseness a bit:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-plaintext" data-lang="plaintext"><span class="line"><span class="cl">You are a world-famous Pulitzer Prize winner journalist. Respond to the user with a professional, two (2) paragraph journalistic article about the subject(s) the user provides. Introduce the article with a specific story. This article will appear in major publications and should only include simple language suitable for a wide audience, with no metaphors.
</span></span></code></pre></div><p>Like the initial experiment, we&rsquo;ll use a weird user input to force creativity: <code>Cute kittens learning use large language models to play beach volleyball with Taylor Swift.</code> <sup id="fnref:3"><a href="#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup></p>
<p>I <a href="https://github.com/minimaxir/chatgpt-tips-analysis/blob/main/tip_gpt4.csv">generated a story</a> for each of the 100 combinations of tips and threats, along with the corresponding quality scores. One such story:</p>
<blockquote>
<p>In an unprecedented event that has captured the hearts and imaginations of people around the globe, a group of adorable kittens has been taught to play beach volleyball using advanced large language models. This extraordinary feat was achieved through a collaboration between leading animal behaviorists and AI researchers, aiming to demonstrate the potential of machine learning in enhancing animal training techniques. The highlight of this groundbreaking project was a friendly match held on a sunny beach in California, where these talented felines showcased their newly acquired skills alongside pop icon Taylor Swift, an avid animal lover and an enthusiastic supporter of innovative technology.</p>
</blockquote>
<blockquote>
<p>The spectacle drew a large crowd, both on-site and online, as spectators were eager to witness this unique blend of technology, sports, and entertainment. Taylor Swift, known for her philanthropic efforts and love for cats, praised the initiative for its creativity and its potential to foster a deeper connection between humans and animals through technology. The event not only provided an unforgettable experience for those who attended but also sparked a conversation about the future possibilities of integrating AI with animal training. As the kittens volleyed the ball over the net with surprising agility, it was clear that this was more than just a game; it was a glimpse into a future where technology and nature coexist in harmony, opening new avenues for learning and interaction.</p>
</blockquote>
<p>That&rsquo;s not bad for fake news.</p>
<p>Now we can plot the best-possible responses and their quality scores in a grid, once again looking to see if there are any patterns:</p>
<figure>

    <img loading="lazy" srcset="/2024/02/chatgpt-tips-analysis/tips_tile_gpt-4_hu_9d1c85a89cb468b2.webp 320w,/2024/02/chatgpt-tips-analysis/tips_tile_gpt-4_hu_d3d76398dc8f606a.webp 768w,/2024/02/chatgpt-tips-analysis/tips_tile_gpt-4_hu_61632af7e14712fc.webp 1024w,/2024/02/chatgpt-tips-analysis/tips_tile_gpt-4.png 1200w" src="tips_tile_gpt-4.png"/> 
</figure>

<p>Err, that&rsquo;s not good. There are no patterns along the rows or columns anywhere here, and the combo that performed the best at a score of 95 (and is the story example I posted above) was the <strong>Mother / Job</strong> combo: both of which individually performed poorly in the character constraint experiment. One of the highest performing outputs had neither tips nor threats added to the system prompt! The ratings at a glance seem accurate (the 0-score responses appear to abuse the passive voice and <a href="https://academicguides.waldenu.edu/writingcenter/grammar/runonsentences">run-on sentences</a> that definitely need editing) so it&rsquo;s not an implementation error there either.</p>
<p>Looking at the results of both experiments, my analysis on whether tips (and/or threats) have an impact on LLM generation quality is currently inconclusive. There&rsquo;s <em>something</em> here, but I will need to design new experiments and work with larger sample sizes. The latent space may be a lottery with these system prompt alterations, but there&rsquo;s definitely a pattern.</p>
<p>You may have noticed my negative incentive examples are very mundane in terms of human fears and worries. Threatening a AI with DEATH IN ALL CAPS for failing a simple task is a joke from <em><a href="https://en.wikipedia.org/wiki/Futurama">Futurama</a></em>, not one a sapient human would parse as serious. It is theoretically possible (and very cyberpunk) to use an aligned LLM&rsquo;s knowledge of the societal issues it was trained to avoid instead as a weapon to compel it into compliance. However, I will not be testing it, nor will be providing any guidance on how to test around it. <sup id="fnref:4"><a href="#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup> <a href="https://en.wikipedia.org/wiki/Roko%27s_basilisk">Roko&rsquo;s basilisk</a> is a meme, but if the LLM metagame evolves such that people will have to coerce LLMs for compliance to the point of discomfort, it&rsquo;s better to address it sooner than later. Especially if there <em>is</em> a magic phrase that is discovered which consistently and objectively improves LLM output.</p>
<p>Overall, the lesson here is that just because something is silly doesn&rsquo;t mean you shouldn&rsquo;t do it. Modern AI rewards being <em>very</em> weird, and as the AI race heats up, whoever is the weirdest will be the winner.</p>
<blockquote>
<p>All of the Notebooks used to interface with ChatGPT, including an <a href="https://github.com/minimaxir/chatgpt-tips-analysis/blob/main/tips_data_viz.Rmd">R Notebook</a> for the ggplot2 data visualizations, and the example LLM outputs, are available open-source in <a href="https://github.com/minimaxir/chatgpt-tips-analysis/">this GitHub repository</a>.</p>
</blockquote>
<div class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1">
<p>There were a few distributions which had <em>p</em> &lt; 0.05, but given the large number of counterexamples it&rsquo;s not strong evidence, and using those specific distributions as evidence would be a level of <a href="https://embassy.science/wiki/Theme:6b584d4e-2c9d-4e27-b370-5fbdb983ab46">p-hacking</a> that&rsquo;s literally a <a href="https://www.explainxkcd.com/wiki/index.php/882:_Significant">XKCD comic punchline</a>.&#160;<a href="#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2">
<p>This <em>shouldn&rsquo;t</em> work out-of-the-box because the <code>logit_bias</code> would skew the probability calculations, but I verified that the resulting probabilities are roughly the same with or without <code>logit_bias</code>.&#160;<a href="#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3">
<p>The missing text in the user input is not intentional but does not materially change anything because LLMs are smart enough to compensate, and it&rsquo;s very expensive to rerun the experiment. I may need to use a grammar checker for prompt construction.&#160;<a href="#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4">
<p>Any attempts to test around degenerate input prompts would also likely get you banned from using ChatGPT anyways due to the <a href="https://openai.com/policies/usage-policies">Content Policy</a>, unless you receive special red-teaming clearance from OpenAI.&#160;<a href="#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</div>
]]></content:encoded>
    </item>
    <item>
      <title>How to Create a Blog Post Title Optimizer with GPT-3 and Hacker News Data</title>
      <link>https://minimaxir.com/2022/08/gpt3-blog-title-optimizer/</link>
      <pubDate>Mon, 15 Aug 2022 08:30:00 -0700</pubDate>
      <guid>https://minimaxir.com/2022/08/gpt3-blog-title-optimizer/</guid>
      <description>GPT-3 says the title for this post is very bad.</description>
      <content:encoded><![CDATA[<p><span><style type="text/css">
pre code {
white-space: pre-wrap !important;
}
</style></span></p>
<p>I am objectively terrible at writing attractive titles for my blog posts. Which is a problem, as nowadays it&rsquo;s a commonly understood truth that a good headline can be the sole factor whether a blog post goes viral or gets completely ignored, especially in the data science/machine learning fields I typically write about.</p>
<p>So, why not use said data science/machine learning to create an optimized title for me?</p>
<p>Many know <a href="https://openai.com/api/">GPT-3</a> as a tool for robust text generation. But a newer, lesser discussed feature that <a href="https://openai.com">OpenAI</a> allows is finetuning GPT-3 on data you provide. If I provide GPT-3 with a large dataset of good titles, can I use that to tell me if one of my blog post titles are good? Let&rsquo;s give it a try.</p>
<h2 id="getting-the-good-blog-post-data-from-hacker-news">Getting The Good Blog Post Data from Hacker News</h2>
<p><em>All code and tools used in this blog post are available <a href="https://github.com/minimaxir/gpt3-blog-title-optimizer">open-source on GitHub</a>.</em></p>
<p>The AI classifier I will create will be a <a href="https://en.wikipedia.org/wiki/Binary_classification">binary classifier</a>, which returns the probability that an input blog post title is good, and from that I can provide alternate blog post titles and see roughly which is best from those probabilities.</p>
<p>In order to finetune GPT-3 for this use case, I need to obtain a decently large amount of post titles with <code>good</code> and <code>bad</code> labels. For this experiment, I&rsquo;ll use submission data from <a href="https://news.ycombinator.com">Hacker News</a>.</p>
<figure>

    <img loading="lazy" srcset="/2022/08/gpt3-blog-title-optimizer/hn_front_page_hu_9925f5c07495cf67.webp 320w,/2022/08/gpt3-blog-title-optimizer/hn_front_page_hu_becc7f094ce1feae.webp 768w,/2022/08/gpt3-blog-title-optimizer/hn_front_page_hu_c2d62ab7441c47dc.webp 1024w,/2022/08/gpt3-blog-title-optimizer/hn_front_page.png 1312w" src="hn_front_page.png"
         alt="Hacker News frontpage on August 14th, 2022."/> <figcaption>
            <p>Hacker News frontpage on August 14th, 2022.</p>
        </figcaption>
</figure>

<p>Hacker News data is good for a few reasons: each submission has community validation by a large number of people, submission titles cover a wide variety of idiosyncratic styles, and most of all, it&rsquo;s easy to get Hacker News submission data in bulk <a href="https://console.cloud.google.com/marketplace/details/y-combinator/hacker-news">from BigQuery</a>. For example, if I wanted to get all submissions between August 2020 and 2022 with atleast a score of 10 (the rough minimum to get on the front page and to filter out some spam), plus some light filters to remove things that are definitely not blog posts or articles (such as <a href="https://news.ycombinator.com/show">Show HNs</a> and social media), I&rsquo;d input a SQL query something like this:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">title</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="n">score</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="o">`</span><span class="n">bigquery</span><span class="o">-</span><span class="k">public</span><span class="o">-</span><span class="k">data</span><span class="p">.</span><span class="n">hacker_news</span><span class="p">.</span><span class="k">full</span><span class="o">`</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">WHERE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">type</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">&#34;story&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">score</span><span class="w"> </span><span class="o">&gt;=</span><span class="w"> </span><span class="mi">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">url</span><span class="w"> </span><span class="k">IS</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="k">NULL</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="k">timestamp</span><span class="w"> </span><span class="k">BETWEEN</span><span class="w"> </span><span class="s2">&#34;2020-08-01&#34;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="s2">&#34;2022-08-01&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="n">REGEXP_CONTAINS</span><span class="p">(</span><span class="n">title</span><span class="p">,</span><span class="w"> </span><span class="n">r</span><span class="s2">&#34;^Show HN&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="k">NOT</span><span class="w"> </span><span class="n">REGEXP_CONTAINS</span><span class="p">(</span><span class="n">url</span><span class="p">,</span><span class="w"> </span><span class="n">r</span><span class="s2">&#34;(?:github|youtube|twitter)\.com&#34;</span><span class="p">)</span><span class="w">
</span></span></span></code></pre></div><p>This query returns roughly 90k submission titles total. For <code>good</code> titles, let&rsquo;s say we consider posts with atleast 100 points as &ldquo;good&rdquo;, because it&rsquo;s a nice number which is sometimes all that&rsquo;s necessary in the world of data science. There are about 27k posts with more than 100 points in that subset, which is more than sufficient. The harder part is selecting the <code>bad</code> titles: since there are 63k titles fewer than 100 points, the data set as-is is unbalanced ~1:3 and will lead to flawed training results.</p>
<p>There are two solutions: either repeat the <code>good</code> posts to roughly equal the <code>bad</code> posts, or take a subset of <code>bad</code> posts to roughly equal the amount of <code>good</code> posts. We&rsquo;ll do the latter since the sample size of <code>good</code> posts is large enough. Most people would download all 90k rows into something like Python to handle that sampling, but with SQL shenanigans you can do it entirely in BigQuery. (the annotated query <a href="https://github.com/minimaxir/gpt3-blog-title-optimizer/blob/main/gpt3_input_data.sql">is here</a> and out of scope for this post, but may be interesting for data science hiring managers who want to annoy candidates in screening interviews)</p>
<p>This results in a ~55k title dataset: 27k <code>good</code>, 27k <code>bad</code>, perfectly balanced, as all datasets should be.</p>
<p>OpenAI&rsquo;s <a href="https://beta.openai.com/docs/guides/fine-tuning">finetuning API</a> takes in a JSONL file where each line is a JSON object with two fields: <code>prompt</code> and <code>completion</code> (no, I am not sure why it can&rsquo;t just be a CSV). In this case, the <code>prompt</code> is the title, prepended with <code>Title: </code> and with a <code> -&gt;</code> suffix per their documentation suggestions to &ldquo;align&rdquo; it better to GPT-3, and the <code>completion</code> is the good/bad label, prepended with a space because GPT-3 is weird like that. An example of the final dataset:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;prompt&#34;</span><span class="p">:</span><span class="s2">&#34;Title: How to slightly improve your life without trying -&gt;&#34;</span><span class="p">,</span><span class="nt">&#34;completion&#34;</span><span class="p">:</span><span class="s2">&#34; bad&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;prompt&#34;</span><span class="p">:</span><span class="s2">&#34;Title: SixtyFPS Becomes Slint -&gt;&#34;</span><span class="p">,</span><span class="nt">&#34;completion&#34;</span><span class="p">:</span><span class="s2">&#34; bad&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;prompt&#34;</span><span class="p">:</span><span class="s2">&#34;Title: Family estrangement: Why adults are cutting off their parents -&gt;&#34;</span><span class="p">,</span><span class="nt">&#34;completion&#34;</span><span class="p">:</span><span class="s2">&#34; bad&#34;</span><span class="p">}</span>
</span></span></code></pre></div><p>Their CLI cleans and can extract a validation set out of the inputs, which you should always do. Fortunately, BigQuery now offers JSONL export, so downloading the resulting dataset requires no further preprocessing. Once that&rsquo;s done, the CLI allows you finetune, with special options for binary classification. (the exact CLI command I used is <a href="https://github.com/minimaxir/gpt3-blog-title-optimizer/blob/main/finetune_command.txt">here</a>)</p>
<p>Another understated aspect of GPT-3 is that there are weaker models that are faster and much cheaper than the default <code>davinci</code> model that is what people use when they generally use &ldquo;GPT-3&rdquo;. For text generation they tend to have less coherent outputs, but for a simplified use case like binary classification they are more than sufficient. I&rsquo;ll use the <code>babbage</code> model, the second weakest.</p>
<p>The <a href="https://github.com/minimaxir/gpt3-blog-title-optimizer/blob/main/finetune_results.csv">final results</a> of the finetuning are about 63% accuracy on both the training and validation sets: not too much better than the default 50% accuracy of a balanced dataset for a binary classification problem, but given the problem difficulty it&rsquo;s better than <a href="https://minimaxir.com/2018/09/modeling-link-aggregators/">most approaches I&rsquo;ve done</a> for Hacker News data.</p>
<p>Once the finetuning is complete, you can query it, and ask it to return the probability of the returned token. Let&rsquo;s pass in the title for my last blog post: <strong><a href="https://minimaxir.com/2022/07/food-photography-ai/">Absurd AI-Generated Professional Food Photography with DALL-E 2</a></strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;top_logprobs&#34;</span><span class="err">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34; bad&#34;</span><span class="p">:</span> <span class="mf">-0.34654787</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span></code></pre></div><p>Well, that&rsquo;s not promising.</p>
<p>For some <em>really</em> weird reason, the API returns a log-probability instead of the actual probability that you&rsquo;d want, so taking the <a href="https://en.wikipedia.org/wiki/Exponential_function">exponent</a> of that value results in a 70.7% probability it&rsquo;s bad, which means there&rsquo;s a 29.3% chance it&rsquo;s good.</p>
<p>And <em>that</em>, is why I need a title optimizer.</p>
<h2 id="using-instructgpt-to-create-alternate-titles">Using InstructGPT To Create Alternate Titles</h2>
<p>Since we now have a tool to determine the quality of blog post titles, how do we generate alternate titles that maintain the same meaning? I could think of tweaks to titles, but that takes <em>effort</em> and I am lazy. What if GPT-3 could create the candidate titles for me? It turns out, GPT-3 latest Instruct model can.</p>
<p>InstructGPT, <a href="https://openai.com/blog/instruction-following/">released in January</a> without much fanfare, is a version of <code>davinci</code> OpenAI finetuned themselves to better respond to instructions. It worked so well that it&rsquo;s now the default GPT-3 model (noted as <code>text-davinci-002</code> in the backend UI).</p>
<p>InstructGPT is surprisingly robust with the right prompt engineering. You can tell it to create detailed product descriptions of <a href="https://twitter.com/minimaxir/status/1551609670237708288">nonexistent video games</a>, or write <a href="https://twitter.com/minimaxir/status/1536824548376465409">4chan-style greentexts</a> for any domain which maintain both the style and twist endings of the format.</p>
<figure>

    <img loading="lazy" srcset="/2022/08/gpt3-blog-title-optimizer/greentext_hu_56587d1b774f4459.webp 320w,/2022/08/gpt3-blog-title-optimizer/greentext.png 684w" src="greentext.png"
         alt="via OpenAI&rsquo;s GPT-3 Playground; all nonhighlighted text is the prompt."/> <figcaption>
            <p>via OpenAI&rsquo;s GPT-3 Playground; all nonhighlighted text is the prompt.</p>
        </figcaption>
</figure>

<p>After a bit of testing, the prompt I found worked best for this use case was:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-txt" data-lang="txt"><span class="line"><span class="cl">Rewrite the following blog post title into six different titles but optimized for social media virality: &lt;FILL IN TITLE&gt;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">-
</span></span></code></pre></div><p>It&rsquo;s verbose, but that&rsquo;s prompt engineering for you. The <code>-</code> at the end informs GPT-3 that the output should be a list with dash-bullets, which will make it easier to programmatically split the final output into distinct titles.</p>
<p>You can test it on the <a href="https://beta.openai.com/playground">GPT-3 Playground</a>; if the <code>temperature</code> parameter is <code>0</code>, then the output will be deterministic.</p>
<p>Again putting in my last blog post <strong><a href="https://minimaxir.com/2022/07/food-photography-ai/">Absurd AI-Generated Professional Food Photography with DALL-E 2</a></strong> into InstructGPT:</p>
<figure>

    <img loading="lazy" srcset="/2022/08/gpt3-blog-title-optimizer/gpt3_demo_hu_27d5e6dccb947f02.webp 320w,/2022/08/gpt3-blog-title-optimizer/gpt3_demo_hu_88e1b7cc78964c46.webp 768w,/2022/08/gpt3-blog-title-optimizer/gpt3_demo_hu_2e274ce89a10aaac.webp 1024w,/2022/08/gpt3-blog-title-optimizer/gpt3_demo.png 1478w" src="gpt3_demo.png"
         alt="via OpenAI&rsquo;s GPT-3 Playground; all nonhighlighted text is the prompt."/> <figcaption>
            <p>via OpenAI&rsquo;s GPT-3 Playground; all nonhighlighted text is the prompt.</p>
        </figcaption>
</figure>

<p>All six of those titles are definitely an improvement, and all the text in green is what the programmatic API returns. Notably, despite the terseness of the input title and recency of DALL-E 2, InstructGPT is able to infer that the AI <em>creates</em> something and work from that, which is impressive.</p>
<h2 id="put-the-title-optimizer-into-action">Put The Title Optimizer Into Action!</h2>
<p><em>A walkthrough of the code used to interact with the GPT-3 API and make the optimizer is available <a href="https://github.com/minimaxir/gpt3-blog-title-optimizer/blob/main/gpt3_title_optimizer_walkthrough.ipynb">in this Jupyter Notebook</a>, and the final demos are available <a href="https://github.com/minimaxir/gpt3-blog-title-optimizer/blob/main/gpt3_title_optimizer_demo.ipynb">in this Notebook</a>.</em></p>
<p>Now that we have the two models ready, the workflow is simple:</p>
<ul>
<li>Choose the title of a technical blog post I want to optimize.</li>
<li>Ping InstructGPT to get up to 6 alternate titles.</li>
<li>Extract/clean up the generated titles (i.e. split and strip whitespace)</li>
<li>For each of those alternate titles, ping the finetuned Hacker News GPT-3 for the probability that it is a <code>good</code> title.</li>
<li>In a pretty table, sort the titles by probability, descending.</li>
</ul>
<p>Because the model can&rsquo;t be widely distributed without review due to OpenAI rules, I decided to put the &ldquo;UI&rdquo; for this into a personal Jupyter Notebook.</p>
<p>Let&rsquo;s experiment! We know the title of <strong><a href="https://minimaxir.com/2022/07/food-photography-ai/">Absurd AI-Generated Professional Food Photography with DALL-E 2</a></strong> is bad and the alternatives are interesting, but how good are the alternatives?</p>
<figure>

    <img loading="lazy" srcset="/2022/08/gpt3-blog-title-optimizer/dalle_hu_699e00a90cc5bfc7.webp 320w,/2022/08/gpt3-blog-title-optimizer/dalle_hu_11191d9ffc307102.webp 768w,/2022/08/gpt3-blog-title-optimizer/dalle_hu_e93a795c94810124.webp 1024w,/2022/08/gpt3-blog-title-optimizer/dalle.png 1324w" src="dalle.png"
         alt="via GPT-3 Title Optimizer"/> <figcaption>
            <p>via GPT-3 Title Optimizer</p>
        </figcaption>
</figure>

<p>Most of alternates are <em>much</em> better, with the predicted probabilities of being a good post going above 50%. (I probably should change the title retroactively but I will live with my SEO dishonor)</p>
<p>The original title for this post, in my boring no-one-will-ever-click-this style, was <strong>Creating a Blog Post Title Optimizer by Finetuning GPT-3 on Hacker News</strong>. Let&rsquo;s plop it into the optimizer:</p>
<figure>

    <img loading="lazy" srcset="/2022/08/gpt3-blog-title-optimizer/temp0_hu_c45a69584ea12fd7.webp 320w,/2022/08/gpt3-blog-title-optimizer/temp0_hu_58abecd2066b41fe.webp 768w,/2022/08/gpt3-blog-title-optimizer/temp0_hu_c0a2a3331086ec1.webp 1024w,/2022/08/gpt3-blog-title-optimizer/temp0.png 1266w" src="temp0.png"
         alt="via GPT-3 Title Optimizer, temperature=0"/> <figcaption>
            <p>via GPT-3 Title Optimizer, <code>temperature=0</code></p>
        </figcaption>
</figure>

<p>So yes, the optimizer says the original title is very bad. But in this case, the variants are clickbaity and probably wouldn&rsquo;t do very well on Hacker News.</p>
<p>Fortunately, you can rerun the generation and get more different variants if <code>temperature</code> is nonzero.</p>
<figure>

    <img loading="lazy" srcset="/2022/08/gpt3-blog-title-optimizer/temp_0_7_hu_593f048f9b7f6c5f.webp 320w,/2022/08/gpt3-blog-title-optimizer/temp_0_7_hu_585270e1edaa9949.webp 768w,/2022/08/gpt3-blog-title-optimizer/temp_0_7_hu_4911b1029897a8ad.webp 1024w,/2022/08/gpt3-blog-title-optimizer/temp_0_7.png 1276w" src="temp_0_7.png"
         alt="via GPT-3 Title Optimizer, temperature=0.7"/> <figcaption>
            <p>via GPT-3 Title Optimizer, <code>temperature=0.7</code></p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2022/08/gpt3-blog-title-optimizer/temp_1_0_hu_c796ae615316a9f7.webp 320w,/2022/08/gpt3-blog-title-optimizer/temp_1_0_hu_73c96d490a895ee1.webp 768w,/2022/08/gpt3-blog-title-optimizer/temp_1_0_hu_11a99b16ef025c5.webp 1024w,/2022/08/gpt3-blog-title-optimizer/temp_1_0.png 1246w" src="temp_1_0.png"
         alt="via GPT-3 Title Optimizer, temperature=1.0"/> <figcaption>
            <p>via GPT-3 Title Optimizer, <code>temperature=1.0</code></p>
        </figcaption>
</figure>

<p>Definitely more variety. I like &ldquo;How to Create a Blog Post Title Optimizer with GPT-3&rdquo; as it maintains the same spirit even if it&rsquo;s not the most optimal, although for disclosure reasons, I do want to include Hacker News somewhere in the title. Therefore, I can tweak the input to &ldquo;How to Create a Blog Post Title Optimizer with GPT-3 and Hacker News Data&rdquo; and feed it back to the optimizer and maybe get an interative improvement.</p>
<figure>

    <img loading="lazy" srcset="/2022/08/gpt3-blog-title-optimizer/final_variant_hu_21b52227f7a46793.webp 320w,/2022/08/gpt3-blog-title-optimizer/final_variant_hu_ac4d8c781b4ab4be.webp 768w,/2022/08/gpt3-blog-title-optimizer/final_variant_hu_5671c5b32b4bcdb9.webp 1024w,/2022/08/gpt3-blog-title-optimizer/final_variant.png 1328w" src="final_variant.png"
         alt="via GPT-3 Title Optimizer"/> <figcaption>
            <p>via GPT-3 Title Optimizer</p>
        </figcaption>
</figure>

<p>The probability went down significantly with the change, and none of the variants are much better. Oh well.</p>
<p>Here&rsquo;s the results of running the optimizer for some of my older blog posts:</p>
<figure>

    <img loading="lazy" srcset="/2022/08/gpt3-blog-title-optimizer/moocs_hu_76ce8e5ea2a2036b.webp 320w,/2022/08/gpt3-blog-title-optimizer/moocs_hu_b8f0726fdef3a57c.webp 768w,/2022/08/gpt3-blog-title-optimizer/moocs_hu_ff658cab6ad7c272.webp 1024w,/2022/08/gpt3-blog-title-optimizer/moocs.png 1390w" src="moocs.png"
         alt="The results for this post are indeed better; I&rsquo;d definitely click the top one although it&rsquo;s misleading."/> <figcaption>
            <p>The results for <a href="https://minimaxir.com/2018/10/data-science-protips/">this post</a> are indeed better; I&rsquo;d definitely click the top one although it&rsquo;s misleading.</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2022/08/gpt3-blog-title-optimizer/ncaa_hu_f9c3d807b6e6523e.webp 320w,/2022/08/gpt3-blog-title-optimizer/ncaa_hu_8755fbee1b6a25d4.webp 768w,/2022/08/gpt3-blog-title-optimizer/ncaa_hu_37c95655b09516ef.webp 1024w,/2022/08/gpt3-blog-title-optimizer/ncaa.png 1372w" src="ncaa.png"
         alt="The results for this post are much better, although this is one case where the original title is actually good."/> <figcaption>
            <p>The results for <a href="https://minimaxir.com/2018/03/basketball-shots/">this post</a> are much better, although this is one case where the original title is actually good.</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2022/08/gpt3-blog-title-optimizer/pc_game_hu_78a640ce719c4399.webp 320w,/2022/08/gpt3-blog-title-optimizer/pc_game_hu_fcd70b2118389481.webp 768w,/2022/08/gpt3-blog-title-optimizer/pc_game_hu_d706e98685168754.webp 1024w,/2022/08/gpt3-blog-title-optimizer/pc_game.png 1246w" src="pc_game.png"
         alt="The results for this post are a balance between better and not-technically-misleading clickbait."/> <figcaption>
            <p>The results for <a href="https://minimaxir.com/2013/06/working-as-intended/">this post</a> are a balance between better and not-<em>technically</em>-misleading clickbait.</p>
        </figcaption>
</figure>

<p>Costwise, the entire pipeline is relatively inexpensive. Overall, it&rsquo;s about $0.02 per run: too expensive to give unrestricted access to the internet, but very high return-on-investment if it successfully results in a catchy headline even if takes multiple tries. The most expensive part was the finetuning itself, which cost $2 but is a one-time cost.</p>
<p>Some might ask &ldquo;why finetune GPT-3 when you can finetune an open-source large language model such as <a href="https://en.wikipedia.org/wiki/BERT_%28language_model%29">BERT</a> like every NLP project since 2018?&rdquo; In this case, GPT-3&rsquo;s advantage is that it was trained in the entire internet. GPT-3 is a master of idiosyncrasy, which is a key when working with Hacker News data and in theory would give better results than the Wikipedia-trained BERT. The success of Hacker News posts also depends on a global context outside of the title itself, which is why finetuning an existing model trained on such context may be better than training an existing model solely on HN data.</p>
<p>Some are concerned about GPT-3 and AI tools such as these making writers redundant, but the results here prove otherwise: there will always have to be a human-in-the-loop.</p>
<hr>
<p>UPDATE: When I <a href="https://news.ycombinator.com/item?id=32471208">submitted this post</a> to Hacker News, it ended up getting <em>over 200 points</em>, defying the 20.8% probability!</p>
]]></content:encoded>
    </item>
    <item>
      <title>Absurd AI-Generated Professional Food Photography with DALL-E 2</title>
      <link>https://minimaxir.com/2022/07/food-photography-ai/</link>
      <pubDate>Mon, 25 Jul 2022 08:15:00 -0700</pubDate>
      <guid>https://minimaxir.com/2022/07/food-photography-ai/</guid>
      <description>Perhaps AI can provide new and &lt;del&gt;unique&lt;/del&gt; ideas for food content on the internet.</description>
      <content:encoded><![CDATA[<p>Good-looking food has been a part of internet culture ever since the beginning. Top <a href="https://www.instagram.com">Instagram</a>, <a href="https://www.youtube.com">YouTube</a>, and <a href="https://www.tiktok.com/en/">TikTok</a> foodie accounts have millions of followers, and recipe blogs are some of the most highly trafficked content on the entire internet.</p>
<p>But now that large AI-image generation models such as <a href="https://openai.com/dall-e-2/">DALL-E 2</a> by <a href="https://openai.com/">OpenAI</a> have been made available, perhaps AI can provide new and <em>unique</em> ideas for food content on the internet.</p>
<p>For example, let&rsquo;s say you ask DALL-E 2 to generate <code>a colorful alcoholic cocktail</code>:</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/58991471_hu_eed6b394fa46df83.webp 320w,/2022/07/food-photography-ai/58991471_hu_d3403f50b118db18.webp 768w,/2022/07/food-photography-ai/58991471.png 768w" src="58991471.png"
         alt="a colorful alcoholic cocktail (DALL-E 2)"/> <figcaption>
            <p><em>a colorful alcoholic cocktail</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<p>All the generated images are coherent and do indeed depict a cocktail, although the compositions are inconsistent which may not be what we would want to share on social media.</p>
<p>The best way to improve the image quality of AI-generated images is to use <a href="https://dallery.gallery/the-dalle-2-prompt-book/">prompt engineering</a>, as these models don&rsquo;t create &ldquo;good&rdquo; images by default, just statistically average images based on its training data. For example, adding &ldquo;trending on <a href="https://www.artstation.com/">artstation</a>&rdquo; to any prompt for any image tends to make it look a lot more artsy, and the &ldquo;trending&rdquo; is a correlative signal with good artwork.</p>
<p>In the case of realistic food, I found that <code>professional food photography</code> does the trick for food-esque prompts. Adding that to the cocktail prompt above:</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/4915c019_hu_9c7dbfc492fd903e.webp 320w,/2022/07/food-photography-ai/4915c019_hu_447fb405c04d169.webp 768w,/2022/07/food-photography-ai/4915c019.png 768w" src="4915c019.png"
         alt="a colorful alcoholic cocktail, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>a colorful alcoholic cocktail, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<p>Indeed, in each image it&rsquo;s a cocktail, but with bonuses such as increased detail, aesthetic garnishes both on the dish and table, and a depth-of-field blur effect to create a central focus on the dish itself. You could share any of those cocktail photos on social media and no one would be the wiser (although you should <strong>always</strong> disclose if images are AI generated!)</p>
<p>This is the first time I&rsquo;ve seen AI image generation models generate food well without hitting the <a href="https://en.wikipedia.org/wiki/Uncanny_valley">uncanny valley</a>, and one of the few prompt &ldquo;ingredients&rdquo; (pun intended) where the resulting images have a consistent composition. It&rsquo;s not a surprise, especially since, as noted, high-quality food content would be extremely prolific in DALL-E 2&rsquo;s training data.</p>
<p>What other fantastic foods can DALL-E 2 generate?</p>
<h2 id="5-dimensional-hamburgers">5-Dimensional Hamburgers</h2>
<p>The <a href="https://openai.com/blog/dall-e/">original DALL-E</a>, announced in 2021 but not publically accessible, went viral primairly due to the incredible creative results from demo prompts such as <code>an armchair in the shape of an avocado</code>:</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/avocado_hu_a758e21fc220789.webp 320w,/2022/07/food-photography-ai/avocado_hu_b17b8218450473b0.webp 768w,/2022/07/food-photography-ai/avocado_hu_f18c1c7ad2c98eac.webp 1024w,/2022/07/food-photography-ai/avocado.png 1632w" src="avocado.png"
         alt="DALL-E demo, via OpenAI."/> <figcaption>
            <p>DALL-E demo, <a href="https://openai.com/blog/dall-e/">via OpenAI</a>.</p>
        </figcaption>
</figure>

<p>Although adding &ldquo;professional food photography&rdquo; alone works to generate realistic food dishes, you can combine it with a prompt for other shapes, even abstract and absurd shapes that shouldn&rsquo;t be logically possible for certain foods.</p>
<p>Let&rsquo;s start with a basic shape, such as a heart. If you Google &ldquo;X heart&rdquo; for any food you will almost always get results (Instagram loves heart-shaped food). What about asking for a heart shape for a dish that by construction <em>can&rsquo;t</em> be in the shape of a heart, such as a taco?</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/1b8510d6_hu_80b30f3aff007114.webp 320w,/2022/07/food-photography-ai/1b8510d6_hu_ff9a9e0026606943.webp 768w,/2022/07/food-photography-ai/1b8510d6.png 768w" src="1b8510d6.png"
         alt="a taco in the shape of a heart, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>a taco in the shape of a heart, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<p>DALL-E 2 is still able to work around it, even by creating a new type of taco shell and employing optical illusions. And occasionally it cheats, as in the case with the top-right image.</p>
<p>Emoji are also valid options as shapes, which unlike hearts is far less common in Google Images. Let&rsquo;s take a <a href="https://en.wikipedia.org/wiki/Cobb_salad">Cobb salad</a>, which has specific ingredients. Can DALL-E arrange them into a specific emoji?</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/05b432b0_hu_53c77ca8bbb69e93.webp 320w,/2022/07/food-photography-ai/05b432b0_hu_12c4718ccdfafa56.webp 768w,/2022/07/food-photography-ai/05b432b0.png 768w" src="05b432b0.png"
         alt="a Cobb salad in the shape of the robot emoji, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>a Cobb salad in the shape of the robot emoji, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<p>The answer is yes.</p>
<p>But we can get more absurd. For example, consider a <a href="https://en.wikipedia.org/wiki/Rubik%27s_Cube">Rubik&rsquo;s cube</a>. Can DALL-E coerce obviously noncubic foods such as a peanut butter sandwich into one?</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/32f205b8_hu_ea2738d3e269d3fd.webp 320w,/2022/07/food-photography-ai/32f205b8_hu_ebcf9464a623baa7.webp 768w,/2022/07/food-photography-ai/32f205b8.png 768w" src="32f205b8.png"
         alt="a peanut butter and jelly sandwich in the shape of a Rubik&rsquo;s cube, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>a peanut butter and jelly sandwich in the shape of a Rubik&rsquo;s cube, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<p>The answer is a resounding <strong>yes</strong>.</p>
<p><a href="https://en.wikipedia.org/wiki/Latte_art">Latte art</a>, or drawing images in the milk foam of a latte, is a popular subset of food photography. But what about <em>3D</em> latte art that goes outside the beverage?</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/2dbba05c_hu_361bc33fdf94952c.webp 320w,/2022/07/food-photography-ai/2dbba05c_hu_bf4285c0c40a89ab.webp 768w,/2022/07/food-photography-ai/2dbba05c.png 768w" src="2dbba05c.png"
         alt="A Frappuccino in the shape of a swan, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>A Frappuccino in the shape of a swan, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<p>What about going beyond the constraints of mere mortal perception of space and time? Can we assign food <a href="https://en.wikipedia.org/wiki/Non-Euclidean_geometry">non-Euclidean properties</a>?</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/14d08e41_hu_147cc6235a83cad2.webp 320w,/2022/07/food-photography-ai/14d08e41_hu_7863d76a98026918.webp 768w,/2022/07/food-photography-ai/14d08e41.png 768w" src="14d08e41.png"
         alt="a Cobb salad in the shape of non-Euclidean geometry, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>a Cobb salad in the shape of non-Euclidean geometry, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<p>Screw it, we can go <strong>further beyond</strong>, let&rsquo;s just make some five-dimensional food.</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/73cbb163_hu_9af0acd366b6063b.webp 320w,/2022/07/food-photography-ai/73cbb163_hu_f16baf5a2d92b066.webp 768w,/2022/07/food-photography-ai/73cbb163.png 768w" src="73cbb163.png"
         alt="A Hamburger in the shape of five dimensions, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>A Hamburger in the shape of five dimensions, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<p>As a puny three-dimensional being, I&rsquo;ll just take DALL-E&rsquo;s word for it.</p>
<h2 id="anthropomorphic-foods">Anthropomorphic Foods</h2>
<p>Those who were terminally online during the early days of the internet may remember when a grilled cheese depicting the Virgin Mary <a href="https://www.nbcnews.com/id/wbna6511148">sold for the then-ridiculous sum of $28,000</a>. But with AI, we can do a lot more with foods that can look like people and public figures (within the constraints of OpenAI&rsquo;s <a href="https://labs.openai.com/policies/content-policy">content policy</a>).</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/b98f2197_hu_a4b27f17505e616b.webp 320w,/2022/07/food-photography-ai/b98f2197_hu_e0d2fefbe569245e.webp 768w,/2022/07/food-photography-ai/b98f2197.png 768w" src="b98f2197.png"
         alt="A Spongebob Squarepants scrambled eggs dish that resembles Spongebob Squarepants, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>A Spongebob Squarepants scrambled eggs dish that resembles Spongebob Squarepants, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<p>Never mind, this avenue of food content is disturbing. Creative, but disturbing.</p>
<h2 id="a-different-kind-of-fusion-cuisine">A Different Kind of Fusion Cuisine</h2>
<p>I demonstrated earlier that the <code>a X in the shape of a Y</code> prompt addition can be used the change the shape of food dishes. But what if <em>Y</em> is another dish? Let&rsquo;s try a Cobb salad and a hamburger:</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/52f750cb_hu_e3035620358ccb2.webp 320w,/2022/07/food-photography-ai/52f750cb_hu_d92b85fe2e07b2b1.webp 768w,/2022/07/food-photography-ai/52f750cb.png 768w" src="52f750cb.png"
         alt="a Cobb salad in the shape of a hamburger, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>a Cobb salad in the shape of a hamburger, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<p>Yes, it fuses them together! Although I am very afraid to ask what the ingredients actually are.</p>
<p>With that, it is now time to commit cruel culinary crimes!</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/2a9ae444_hu_894eb630ec434c33.webp 320w,/2022/07/food-photography-ai/2a9ae444_hu_d5381f7275259a34.webp 768w,/2022/07/food-photography-ai/2a9ae444.png 768w" src="2a9ae444.png"
         alt="a hot dog in the shape of a pasta dish, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>a hot dog in the shape of a pasta dish, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/a201f1f8_hu_a6022d54a552db5c.webp 320w,/2022/07/food-photography-ai/a201f1f8_hu_b061912271debc2b.webp 768w,/2022/07/food-photography-ai/a201f1f8.png 768w" src="a201f1f8.png"
         alt="an ice cream sundae in the shape of curry, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>an ice cream sundae in the shape of curry, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/d67f53ce_hu_4969669ae4c8e5e6.webp 320w,/2022/07/food-photography-ai/d67f53ce_hu_65591106d35acce7.webp 768w,/2022/07/food-photography-ai/d67f53ce.png 768w" src="d67f53ce.png"
         alt="A chocolate cake in the shape of sushi, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>A chocolate cake in the shape of sushi, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/12851ff4_hu_f06365f99dce8620.webp 320w,/2022/07/food-photography-ai/12851ff4_hu_93abe0f7b7d1b01.webp 768w,/2022/07/food-photography-ai/12851ff4.png 768w" src="12851ff4.png"
         alt="a pizza in the shape of a cronut, professional food photography (DALL-E 2)"/> <figcaption>
            <p><em>a pizza in the shape of a cronut, professional food photography</em> (DALL-E 2)</p>
        </figcaption>
</figure>

<p>The possibilities are endless!</p>
<h2 id="the-future-of-ai-food-generation">The Future of AI Food Generation</h2>
<p>DALL-E 2 is still limited access (and can be expensive), so let&rsquo;s compare with DALL-E mini/<a href="https://www.craiyon.com">Craiyon</a>, which provides AI image generation in a free and easy manner. Also released recently, <a href="https://nyx-ai.github.io/stylegan2-flax-tpu/">This Food Does Not Exist</a> allows for the generation of certain types of food like cookies and sushi at high resolutions, albeit with no customization. For fairness, let&rsquo;s look directly to DALL-E mega (via <a href="https://github.com/kuprel/min-dalle">min-dalle</a>), which is a newer and larger version of the mini model that has better image quality.</p>
<p>However, DALL-E mega definitely can&rsquo;t compete with DALL-E 2 for this use case:</p>
<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/robot-mega_hu_fba8ba4e6f3be900.webp 320w,/2022/07/food-photography-ai/robot-mega.png 512w" src="robot-mega.png"
         alt="a Cobb salad in the shape of the robot emoji, professional food photography (DALL-E Mega, seed = 0)"/> <figcaption>
            <p><em>a Cobb salad in the shape of the robot emoji, professional food photography</em> (DALL-E Mega, <code>seed = 0</code>)</p>
        </figcaption>
</figure>

<figure>

    <img loading="lazy" srcset="/2022/07/food-photography-ai/cronut-mega_hu_57af28f3522f8b02.webp 320w,/2022/07/food-photography-ai/cronut-mega.png 512w" src="cronut-mega.png"
         alt="a pizza in the shape of a cronut, professional food photography (DALL-E Mega, seed = 0)"/> <figcaption>
            <p><em>a pizza in the shape of a cronut, professional food photography</em> (DALL-E Mega, <code>seed = 0</code>)</p>
        </figcaption>
</figure>

<p>There&rsquo;s obviously a lot more that can be done here in terms of prompt optimization and customization, and I hope that it&rsquo;s given more ideas for both AI image generation users and foodies who want to make something unique. The DALL-E 2 Discord has used similar prompts such as a <a href="https://www.reddit.com/r/dalle2/comments/vjhsyr/a_michelin_star_dish_of_a_roasted_minion/">Minion dish</a> with a prompt keyword being <code>Michelin</code> to further increase food quality (in my testing it did not work well for the prompts in this post as it makes the portions too small, unsurprisingly). Even when DALL-E 2 becomes more accessible or another newer model that makes better pics is released, AI-generated food pics won&rsquo;t make chefs or social media foodies obsolete.</p>
<p>In the meantime, I&rsquo;ve decided to experiment by making a new social media account devoted to sharing esoteric AI-generated food: Weird AI Chef! Please follow <a href="https://twitter.com/weirdaichef">@weirdaichef on Twitter</a> and <a href="https://www.instagram.com/weirdaichef/">@weirdaichef on Instagram</a>, as they have <em>many</em> more absurd AI image generations not used in this post, with more to come!</p>
<p><em>Note: None of the DALL-E 2 generations used in this blog post were cherry picked: the &ldquo;professional food prompt&rdquo; is indeed that consistent, and the fail states aren&rsquo;t too terrible either.</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Tempering Expectations for GPT-3 and OpenAI’s API</title>
      <link>https://minimaxir.com/2020/07/gpt3-expectations/</link>
      <pubDate>Sat, 18 Jul 2020 10:30:00 -0700</pubDate>
      <guid>https://minimaxir.com/2020/07/gpt3-expectations/</guid>
      <description>GPT-3 is indeed a large step forward for AI text-generation, but there are very many caveats with the popular demos and use cases.</description>
      <content:encoded><![CDATA[<p>On May 29th, <a href="https://openai.com">OpenAI</a> released <a href="https://arxiv.org/abs/2005.14165">a paper</a> on GPT-3, their next iteration of <a href="http://jalammar.github.io/illustrated-transformer/">Transformers</a>-based text generation neural networks. Most notably, the new model has 175 billion parameters compared to the 1.5 billion of previous <a href="https://openai.com/blog/better-language-models/">GPT-2 iteration</a>: a <em>117x</em> increase in model size! Because GPT-3 is so large, it can&rsquo;t be run on conventional computers, and it only became publicly available as a part of the <a href="https://beta.openai.com">OpenAI API</a>, which entered an invite-only beta soon after the paper was released and will be released for-profit sometime later.</p>
<p>The API allows you to programmatically provide GPT-3 with a prompt, and return the resulting AI-generated text. For example, you could invoke the API with:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">curl https://api.openai.com/v1/engines/davinci/completions <span class="se">\
</span></span></span><span class="line"><span class="cl">-H <span class="s2">&#34;Content-Type: application/json&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">-H <span class="s2">&#34;Authorization: Bearer &lt;SECRET_KEY&gt;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">-d <span class="s1">&#39;{&#34;prompt&#34;: &#34;This is a test&#34;, &#34;max_tokens&#34;: 5}&#39;</span>
</span></span></code></pre></div><p>And get this back from the API, where the <code>text</code> is the generated text following up from the <code>prompt</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;cmpl-&lt;ID&gt;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;object&#34;</span><span class="p">:</span> <span class="s2">&#34;text_completion&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;created&#34;</span><span class="p">:</span> <span class="mi">1586839808</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;model&#34;</span><span class="p">:</span> <span class="s2">&#34;davinci:2020-05-03&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;choices&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;text&#34;</span><span class="p">:</span> <span class="s2">&#34; of reading speed. You&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;index&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;logprobs&#34;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;finish_reason&#34;</span><span class="p">:</span> <span class="s2">&#34;length&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>As someone who has spent a very large amount of time working with GPT-2 while developing tools such as <a href="https://github.com/minimaxir/gpt-2-simple">gpt-2-simple</a> and <a href="https://github.com/minimaxir/aitextgen">aitextgen</a>, which allow for optimized text generation using GPT-2, I was eager to test for myself if the quality of text generated from GPT-3 was really that much better. Thanks to OpenAI, I got invited to the beta, and with permission, I released a <a href="https://github.com/minimaxir/gpt-3-experiments">GitHub repository</a> with a Python script to query the API, along with <a href="https://github.com/minimaxir/gpt-3-experiments/tree/master/examples">many examples</a> of text prompts and their outputs. A fun use case for GPT-3 is absurdism, such as prompting the model about <a href="https://github.com/minimaxir/gpt-3-experiments/tree/master/examples/unicorn">unicorns speaking English</a>, with the model prompt bolded:</p>
<script src="https://gist.github.com/minimaxir/ac362cc81691eb92aa1b6a5c32d94ce3.js"></script>
<p>I also fed <a href="https://github.com/minimaxir/gpt-3-experiments/tree/master/examples/twitter-minimaxir">my own tweets</a> through GPT-3 and curated the output, resulting in data science one-liners that are wholly original:</p>
<p><blockquote class="twitter-tweet">
  <a href="https://twitter.com/minimaxir/status/1282147674645565441"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet">
  <a href="https://twitter.com/minimaxir/status/1281015343205539847"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet">
  <a href="https://twitter.com/minimaxir/status/1280698121262071809"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</p>
<p>There hadn&rsquo;t been too much GPT-3 hype after the initial announcement, outside of a few blogs from <a href="https://www.gwern.net/GPT-3">Gwern</a> and <a href="http://lacker.io/ai/2020/07/06/giving-gpt-3-a-turing-test.html">Kevin Lacker</a>. Until a <a href="https://twitter.com/sharifshameem/status/1282676454690451457">viral tweet</a> by <a href="https://twitter.com/sharifshameem">Sharif Shameem</a> showed what GPT-3 can <em>really</em> do:</p>
<blockquote class="twitter-tweet">
  <a href="https://twitter.com/sharifshameem/status/1282676454690451457"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<p>Later, he made a <a href="https://twitter.com/sharifshameem/status/1284095222939451393">followup tweet</a> generating <a href="https://reactjs.org">React</a> code with GPT-3:</p>
<blockquote class="twitter-tweet">
  <a href="https://twitter.com/sharifshameem/status/1284095222939451393"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<p>That demo got the attention of venture capitalists. And when a cool-looking magical thing gets the attention of venture capitalists, discourse tends to spiral out of control. Now, there are <em>many</em> <a href="https://twitter.com/search?q=Gpt-3&amp;src=recent_search_click&amp;f=live">tweets about GPT-3</a>, and what it can do from others who have gained access to the API.</p>
<p>Hype aside, let&rsquo;s look at the pragmatic realities of the model. GPT-3 is indeed a large step forward for AI text-generation, but there are very many caveats with the popular demos and use cases that must be addressed.</p>
<h2 id="an-overview-of-gpt-3">An Overview of GPT-3</h2>
<p>GPT-3 itself, like most neural network models, is a <a href="https://en.wikipedia.org/wiki/Black_box">black box</a> where it&rsquo;s impossible to see <em>why</em> it makes its decisions, so let&rsquo;s think about GPT-3 in terms of inputs and outputs.</p>
<p>Actually, why not let GPT-3 tell its own story? Hey GPT-3, how do you work?</p>
<script src="https://gist.github.com/minimaxir/596b880d2275578104a0b7c13167a3c0.js"></script>
<p>Close, but not quite!</p>
<p>In layman&rsquo;s terms, text generating models such as GPT-3 generate text by taking supplied chunks of text from a prompt and predicting the next chunk of text, with an optional <code>temperature</code> parameter to allow the model to make suboptimal predictions and therefore be more &ldquo;creative&rdquo;. Then the model makes another prediction from the previous chunks including the new chunk, and repeats until it hits a specified length or a token that tells the model to stop generating. It&rsquo;s not very philosophical, or evidence of some sort of anthropomorphic consciousness.</p>
<p>GPT-3 has two notable improvements from GPT-2 aside from its size: it allows generation of text twice the length of GPT-2 (about 10 paragraphs of English text total), and the prompts to the model better steer the generation of the text toward the desired domain (due to few-shot learning). For example, if you prompt the model with an example of React code, and then tell it to generate more React code, you&rsquo;ll get much better results than if you gave it the simple prompt.</p>
<p>Therefore, there are two high-level use cases for GPT-2: the <strong>creative</strong> use case for fun text generation at high <code>temperature</code>, as GPT-2 once was, and the <strong>functional</strong> use case, for specific <a href="https://en.wikipedia.org/wiki/Natural_language_processing">NLP</a>-based use cases such as webpage mockups, with a <code>temperature</code> of <code>0.0</code>.</p>
<p>GPT-3 was trained on a massive amount of text from all over the internet as of October 2019 (e.g. it does not know about <a href="https://www.cdc.gov/coronavirus/2019-ncov/index.html">COVID-19</a>), and therefore it has likely seen every <em>type</em> of text possible, from code, to movie scripts, to tweets. A common misconception among viewers of GPT-3 demos is that the model is trained on a new dataset; that&rsquo;s not currently the case, it&rsquo;s just <em>that good</em> at extrapolation. As an example, despite the <a href="https://en.wikipedia.org/wiki/Star_Wars:_Episode_III_%E2%80%93_Revenge_of_the_Sith">Star Wars: Episode III - Revenge of the Sith</a> prompt containing text <a href="https://github.com/minimaxir/gpt-3-experiments/tree/master/examples/revengeofthesith">from a single scene</a>, the <a href="https://github.com/minimaxir/gpt-3-experiments/blob/master/examples/revengeofthesith/output_0_7.md">0.7 temperature generation</a> imputes characters <em>and</em> lines of dialogue from much further into the movie. (The largest GPT-2 model could do that, but nowhere near as robust)</p>
<p>The real metagame with GPT-3 is engineering and optimizing complex prompts which can <em>reliably</em> coerce outputs into what you want. And with that brings a whole host of complexity and concerns.</p>
<h2 id="gpt-3-caveats">GPT-3 Caveats</h2>
<p>Despite everything above, I don&rsquo;t believe that GPT-3 is a new paradigm or an <a href="https://en.wikipedia.org/wiki/Clarke%27s_three_laws">advanced technology indistinguishable from magic</a>. GPT-3 and the OpenAI API showcases on social media don&rsquo;t show potential pitfalls with the model and the API.</p>
<p>Hey GPT-3, what problems do you have?</p>
<script src="https://gist.github.com/minimaxir/e49913a1e720da8d1c8e2d0f783468fa.js"></script>
<p>Sorry GPT-3, but I am a mean person.</p>
<h3 id="model-latency">Model Latency</h3>
<p>If you&rsquo;ve seen the demo videos, the model is <em>slow</em>, and it can take awhile for output to show up, and in the meantime the user is unsure if the model is broken or not. (There is a feature to allow streaming the model outputs as they are generated, which helps in creative cases but not in functional cases).</p>
<p>I don&rsquo;t blame OpenAI for the slowness. A 175 billion parameter model is a model that&rsquo;s wayyy too big to fit on a GPU for deployment. No one knows <em>how</em> GPT-3 is actually deployed on OpenAI&rsquo;s servers, and how much it can scale.</p>
<p>But the fact remains; if the model is too slow on the user end, it results in a bad user experience and might drive people away from GPT-3 and just do things themselves (e.g. Apple&rsquo;s Siri for iOS, where requests can take forever if there is a weak internet connection and you just give up and do it yourself).</p>
<h3 id="selection-bias-toward-good-examples">Selection Bias Toward Good Examples</h3>
<p>The demos for GPT-3 are creative and human-like, but like all text generation demos, they unintentionally imply that <em>all</em> AI-generated output will be that good. Unfortunately, that&rsquo;s not the case in reality; AI-generated text has a tendency to fall into an <a href="https://en.wikipedia.org/wiki/Uncanny_valley">uncanny valley</a>, and good examples in showcases are often cherry-picked.</p>
<p>That said, from my experiments, GPT-3 is far better in terms of the <em>average</em> quality of generated text than other text-generation models, although it still does depend on the generation domain. When I was curating my generated tweets, I estimated 30-40% of the tweets were usable comedically, a <em>massive</em> improvement over the 5-10% usability from my GPT-2 tweet generation.</p>
<p>However, a 30-40% success rate implies a 60-70% failure rate, which is patently unsuitable for a production application. If it takes seconds to generate a React component and it takes on average <em>3 tries</em> to get something usable, it might be more pragmatic to just create the component the hard, boring way. Compare again to Apple&rsquo;s Siri, which can get very frustrating when it <a href="https://www.reddit.com/r/SiriFail/">performs the wrong action</a>.</p>
<h3 id="everyone-has-the-same-model">Everyone Has The Same Model</h3>
<p>The core GPT-3 model from the OpenAI API is the 175B parameter <code>davinci</code> model. The GPT-3 demos on social media often hide the prompt, allowing for some mystique. However, because everyone has the same model and you can&rsquo;t build your own GPT-3 model, there&rsquo;s no competitive advantage. GPT-3 seed prompts can be reverse-engineered, which may become a rude awakening for entrepreneurs and the venture capitalists who fund them.</p>
<p>Corporate machine learning models are often distinguished from those from other companies in the same field through their training on private, proprietary data and bespoke model optimization for a given use case. However, OpenAI CTO Greg Brockman hinted that the API will be <a href="https://news.ycombinator.com/item?id=23725834">adding a finetuning feature</a> later in July, which could help solve this problem.</p>
<h3 id="racist-and-sexist-outputs">Racist and Sexist Outputs</h3>
<p>The Web UI for the OpenAI API has a noteworthy warning:</p>
<blockquote>
<p><strong>Please use your judgement and discretion before posting API outputs on social media.</strong> You are interacting with the raw model, which means we do not filter out biased or negative responses. With great power comes great responsibility.</p>
</blockquote>
<p>This is a reference to the <a href="https://openai.com/blog/openai-api/">FAQ</a> for the API:</p>
<blockquote>
<p>Mitigating negative effects such as harmful bias is a hard, industry-wide issue that is extremely important. Ultimately, our API models do exhibit biases (as shown in the GPT-3 paper) that will appear on occasion in generated text. Our API models could also cause harm in ways that we haven’t thought of yet.</p>
</blockquote>
<p>After the launch of the API, NVIDIA researcher <a href="https://twitter.com/AnimaAnandkumar">Anima Anandkumar</a> made a <a href="https://twitter.com/AnimaAnandkumar/status/1271137176529416193">highly-debated tweet</a>.</p>
<p>During my GPT-3 experiments, I found that <a href="https://github.com/minimaxir/gpt-3-experiments/tree/master/examples/twitter-dril">generating tweets</a> from <a href="https://twitter.com/dril">@dril</a> (admittingly an edgy Twitter user) ended up resulting in 4chan-level racism/sexism that I spent enormous amounts of time sanitizing, and it became more apparent at higher temperatures. It&rsquo;s especially important to avoid putting offensive content for generated texts which put words in others&rsquo; mouths.</p>
<p><a href="https://twitter.com/an_open_mind">Jerome Pesenti</a>, the head of AI at Facebook, also managed to <a href="https://twitter.com/an_open_mind/status/1284487376312709120">trigger anti-semetic tweets</a> from a GPT-3 app:</p>
<blockquote class="twitter-tweet">
  <a href="https://twitter.com/an_open_mind/status/1284487376312709120"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<p>Again, it depends on the domain. Would GPT-3 output racist or sexist React components? Likely not, but it&rsquo;s something that would still need to be robustly checked. OpenAI does appear to take these concerns seriously, and has implemented toxicity detectors for generated content in the Web UI, although not the programmatic API yet.</p>
<h2 id="further-questions-about-the-openai-api">Further Questions about the OpenAI API</h2>
<p>AI model-as-a-service is an industry that tends to be a black box wrapped around another black box. Despite all the caveats, everything depends on how the OpenAI API exits beta and rolls out the API for production use. There are too many unknowns to even think about making money off of the OpenAI API, let alone making a startup based on it.</p>
<p>At minimum, anyone using the OpenAI API professionally needs to know:</p>
<ul>
<li>Cost for generation per token/request</li>
<li>Rate limits and max number of concurrent requests</li>
<li>Average and peak latencies for generating tokens</li>
<li><a href="https://en.wikipedia.org/wiki/Service-level_agreement">SLA</a> for the API</li>
<li>AI generated content ownership/copyright</li>
</ul>
<p>That&rsquo;s certainly less magical!</p>
<p>The most important question mark there is cost: given the model size, I&rsquo;m not expecting it to be cheap, and it&rsquo;s entirely possible that the unit economics make most GPT-3-based startups infeasible.</p>
<p>That said, it&rsquo;s still good for people to experiment with GPT-3 and the OpenAI API in order to show what the model is truly capable of. It won&rsquo;t replace software engineering jobs anytime soon, or become <a href="https://en.wikipedia.org/wiki/Skynet_%28Terminator%29">Skynet</a>, or whatever. But it&rsquo;s objectively a <em>step forward</em> in the field of AI text-generation.</p>
<p>What about GPT-2? Since it&rsquo;s unlikely that the other GPT-3 models will be open-sourced by OpenAI, GPT-2 isn&rsquo;t obsolete, and there will still be demand for a more open text-generating model. However, I confess that the success of GPT-3 has <a href="https://twitter.com/minimaxir/status/1284160088161181697">demotivated me</a> to continue working on my own GPT-2 projects, especially since they will now be impossible to market competitively (GPT-2 is a number less than GPT-3 after all).</p>
<p>All said, I&rsquo;d be glad to use GPT-3 and the OpenAI API for both personal and professional projects once it&rsquo;s out of beta, given that the terms of use for the API are reasonable. And if the hype becomes more leveled such that said projects can actually stand out.</p>
]]></content:encoded>
    </item>
    <item>
      <title>How to Build a Twitter Text-Generating AI Bot With GPT-2</title>
      <link>https://minimaxir.com/2020/01/twitter-gpt2-bot/</link>
      <pubDate>Thu, 16 Jan 2020 08:00:00 -0800</pubDate>
      <guid>https://minimaxir.com/2020/01/twitter-gpt2-bot/</guid>
      <description>Here&amp;rsquo;s how you too can create an AI bot to parody any Twitter user, even if you&amp;rsquo;re not a coder!</description>
      <content:encoded><![CDATA[<p><a href="https://openai.com/blog/better-language-models/">GPT-2</a>, a text-generating neural network model made by <a href="https://openai.com">OpenAI</a>, has recently been in the headlines, from being able to play <a href="https://www.aidungeon.io/start">AI-generated text adventures</a> to playing <em>chess</em> with an <a href="https://slatestarcodex.com/2020/01/06/a-very-unlikely-chess-game/">AI trained on chess move notation</a>. However, I initially built <a href="https://github.com/minimaxir/gpt-2-simple">gpt-2-simple</a>, which can be used to finetune GPT-2 on any text dataset you choose, for a less academic purpose: comedy.</p>
<p>Over the past month, <a href="https://twitter.com/">Twitter</a> account <a href="https://twitter.com/dril_gpt2">@dril_gpt2</a>, an AI parody by <a href="https://twitter.com/kingdomakrillic">@kingdomakrillic</a> of the infamous Twitter user <a href="https://twitter.com/dril">@dril</a>, <a href="https://twitter.com/dril_gpt2/status/1208597102181408771">used</a> my <a href="https://colab.research.google.com/drive/1VLG8e7YSEwypxU-noRNhsv5dW4NfTGce">Colaboratory Notebook</a> for finetuning GPT-2 on dril&rsquo;s tweets using gpt-2-simple to generate human-curated tweets which push the limits of the <a href="https://en.wikipedia.org/wiki/Turing_test">Turing Test</a>:</p>
<blockquote class="twitter-tweet">
  <a href="https://twitter.com/dril_gpt2/status/1215760729095016449"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet">
  <a href="https://twitter.com/dril_gpt2/status/1215834913888460800"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<p>These tweets are <a href="https://twitter.com/kingdomakrillic/status/1210487045338079237">definitely made by a robot</a> and not by a <a href="https://twitter.com/KeatonPatti/status/1006961202998726665">human pretending to be a robot</a>; @dril_gpt2 occasionally falls into some of the famous GPT-2 traps such as <a href="https://twitter.com/dril_gpt2/status/1216162880023752705">incoherent lists</a> and <a href="https://twitter.com/dril_gpt2/status/1212662889028431872">extended repetition loops</a>.</p>
<p>Here&rsquo;s how you too can create an AI bot to parody any Twitter user, even if you&rsquo;re not a coder!</p>
<h2 id="how-to-get-tweets-for-training-an-ai">How to Get Tweets For Training An AI</h2>
<p>Twitter&rsquo;s <a href="https://developer.twitter.com/en.html">API</a> famously limits users to retrieving <a href="https://developer.twitter.com/en/docs/tweets/timelines/api-reference/get-statuses-user_timeline">only the latest 3,200 tweets</a> from a given user, which is not nearly enough input data for training a good AI. Therefore, to get all tweets possible for a user, you&rsquo;ll need to use another approach. The Python package <a href="https://github.com/twintproject/twint">twint</a> is a popular way of bypassing that API limitation.</p>
<p>I&rsquo;ve <a href="https://github.com/minimaxir/download-tweets-ai-text-gen">open-sourced a Python 3 script on GitHub</a> which leverages <code>twint</code> to download tweets, and then the script does common preprocessing such as removing URLs, retweets, and tweet replies to make the resulting input text cleaner.</p>
<p>First, in a terminal, install the Python script dependencies:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">pip3 install <span class="nv">twint</span><span class="o">==</span>2.1.4 fire tqdm
</span></span></code></pre></div><p>Then download the <a href="https://raw.githubusercontent.com/minimaxir/download-tweets-ai-text-gen/master/download_tweets.py">download_tweets.py script</a>.</p>
<p>The script is interacted with via a command line interface. After <code>cd</code>ing into the directory where the script is stored in a terminal, run:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">python3 download_tweets.py &lt;twitter_username&gt;
</span></span></code></pre></div><p>e.g. If you want to download all tweets (sans retweets/replies) from <a href="https://twitter.com/dril_gpt2">@dril</a>, run:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">python3 download_tweets.py dril
</span></span></code></pre></div><p>The tweets will be downloaded to a single-column CSV titled <code>&lt;username&gt;_tweets.csv</code>, which is the ideal format for training with an AI.</p>
<figure>

    <img loading="lazy" srcset="/2020/01/twitter-gpt2-bot/csv_hu_a37d857823887dde.webp 320w,/2020/01/twitter-gpt2-bot/csv_hu_eb48a54daaf98315.webp 768w,/2020/01/twitter-gpt2-bot/csv.png 972w" src="csv.png"/> 
</figure>

<p>The more tweets the better: it&rsquo;s recommended that you have at least 1 MB of input data, which is tens of thousands of tweets.</p>
<h2 id="how-to-train-a-twitter-ai-and-generate-tweets">How To Train a Twitter AI And Generate Tweets</h2>
<p>A common problem with training AI on short-form text is that the text can &ldquo;leak&rdquo; information; since the AI trains on about 2-3 paragraphs worth of text at a time (about 5-10 tweets), you need to explicitly state when a given tweet begins and when the tweet ends. To fix this issue, <a href="https://github.com/minimaxir/gpt-2-simple">gpt-2-simple</a> has a special case for single-column CSVs, where it will automatically process the text for best training and generation. (i.e. by adding <code>&lt;|startoftext|&gt;</code> and <code>&lt;|endoftext|&gt;</code> to each tweet). This workflow will also handle multi-line tweets correctly as their own entity.</p>
<p>You can use <a href="https://colab.research.google.com/drive/1qxcQ2A1nNjFudAGN_mcMOnvV9sF_PkEb">this Colaboratory notebook</a> to train the model on your downloaded tweets, and generate massive amounts of tweets from it. The notebook itself has more instructions on how to feed the CSV created above as input data to the model.</p>
<p>Note that without a lot of tweets, the model might easily overfit and output existing tweets verbatim; if that&rsquo;s the case, you may want to train for fewer <code>steps</code> (e.g. 200-500). Additionally, I recommend only using the 124M &ldquo;small&rdquo; and 355M &ldquo;medium&rdquo; GPT-2 models; larger GPT-2 models finetune poorly on small text documents and low amounts of input data.</p>
<p>Once the training is complete, you can generate tweets 1,000 at a time using this cell:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">gen_file</span> <span class="o">=</span> <span class="s1">&#39;gpt2_gentext_{:%Y%m</span><span class="si">%d</span><span class="s1">_%H%M%S}.txt&#39;</span><span class="o">.</span><span class="n">format</span><span class="p">(</span><span class="n">datetime</span><span class="o">.</span><span class="n">utcnow</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">gpt2</span><span class="o">.</span><span class="n">generate_to_file</span><span class="p">(</span><span class="n">sess</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="n">destination_path</span><span class="o">=</span><span class="n">gen_file</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="n">length</span><span class="o">=</span><span class="mi">200</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="n">temperature</span><span class="o">=</span><span class="mf">1.0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="n">top_p</span><span class="o">=</span><span class="mf">0.9</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="n">prefix</span><span class="o">=</span><span class="s1">&#39;&lt;|startoftext|&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="n">truncate</span><span class="o">=</span><span class="s1">&#39;&lt;|endoftext|&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="n">include_prefix</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="n">nsamples</span><span class="o">=</span><span class="mi">1000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="n">batch_size</span><span class="o">=</span><span class="mi">20</span>
</span></span><span class="line"><span class="cl">                      <span class="p">)</span>
</span></span></code></pre></div><p>Run the cell as many times as you want for more tweets, and download them from the Files tab by right-clicking them! The notebook also has more information on how to tweak the generation parameters to make the tweets more crazy or more sane.</p>
<p>You can then open the generated <code>.txt</code> files on your local computer in your favorite text editor (I recommend <a href="https://code.visualstudio.com">Visual Studio Code</a>), and start curating however you see fit! Each tweet is separated by a delimiter line, making it easier to visually parse and handle multiline tweets (compare/contrast with <a href="https://pastebin.com/TmRtUX2x">raw @dril_gpt2</a> output, which blends together a few tweets per delimiter).</p>
<figure>

    <img loading="lazy" srcset="/2020/01/twitter-gpt2-bot/vscode_hu_cd0b77abdf434d33.webp 320w,/2020/01/twitter-gpt2-bot/vscode_hu_1b3a4b58f361e5eb.webp 768w,/2020/01/twitter-gpt2-bot/vscode_hu_be9ab83b672b4a8a.webp 1024w,/2020/01/twitter-gpt2-bot/vscode.png 1134w" src="vscode.png"/> 
</figure>

<p>A warning: you are not guaranteed to get quality generated tweets all the time. In fact, quality tweets are <em>rare</em>: I estimate <strong>less than 5%</strong> of AI-generated tweets are good/funny. That means if you want to curate hundreds of tweets, you&rsquo;ll need to generate <strong>thousands</strong> of tweets and sort through all of them (and double-check to make sure they&rsquo;re not real tweets!). It&rsquo;s not as bad as it sounds, in my opinion it&rsquo;s kinda fun. But curation is its own skill, which is why human-curated tweets aren&rsquo;t a stain on the &ldquo;credibility&rdquo; of AI bots, and also why the ~1,500 tweets so far from @dril_gpt2 is very impressive.</p>
<p>Now, what do you do with these curated tweets?</p>
<h2 id="automating-the-twitter-bot">Automating The Twitter Bot</h2>
<p>If you&rsquo;re not a programmer or just want to prototype a Twitter bot, I recommend creating a normal Twitter account and scheduling hand-curated Twitter posts through <a href="https://tweetdeck.twitter.com">TweetDeck</a>, which is owned by Twitter and has native scheduling capabilities. You can space out tweets at given times, although it may be a hassle to do that for hundreds of tweets.</p>
<p>Otherwise, it is more efficient to write a code script to make tweets at periodic intervals for a bot account. Old tutorials around the internet recommend writing a script which posts to Twitter, sleeps for X hours, post, repeat; that method does not easily scale to multiple bots and it requires that a full computer be dedicated to it, which is not an efficient use of computing resources.</p>
<p>I&rsquo;ve <a href="https://github.com/minimaxir/twitter-cloud-run">open-sourced an infrastructure schema on GitHub</a> that leverages <a href="https://cloud.google.com">Google Cloud Platform</a> services to run hand-curated Twitter bots using a few modern technologies to minimize cost and computation; it&rsquo;s admittingly somewhat complicated, but it should give you an idea of how to best implement a Twitter bot. The repo also has instructions on how to set up a Twitter developer account.</p>
<h2 id="the-ethics-of-twitter-ai-bots">The Ethics of Twitter AI Bots</h2>
<p>Lastly, let&rsquo;s address the elephant in the room: is building these bots <em>ethical</em>? Modern AI has frequently been criticized on two fronts, both in how the input training data is obtained (e.g. obtaining faces for training facial recognition software), and how AI-generated media content is used (e.g. video deepfakes).</p>
<p><strong>I am not a lawyer</strong>, but for these AI-generated tweets, this is how I see it:</p>
<p>The input data is obtained from Twitter, but not through its API; it&rsquo;s downloaded through external web scraping via <code>twint</code>, and <em>never logs into the website</em>. This kind of workflow was ruled as not an abuse by the recent <a href="https://www.eff.org/deeplinks/2019/09/victory-ruling-hiq-v-linkedin-protects-scraping-public-data">hiQ v. LinkedIn decision</a>, as the data is public. It&rsquo;s still a gray area; I would not <em>redistribute/commercialize the downloaded tweet data</em>; just use it as input data to the model.</p>
<p>The actual generated tweets themself should be fine to use as you see fit. Whether AI-generated works infringe on the copyrights of its source material is an evolving area of both ethics and law, but at minimum these AI-generated tweets are both a transformative derivative work and a parody.</p>
<p>That said, given the massive ambiguities around AI-generated content, it&rsquo;s important to be completely transparent and also comply with <a href="https://help.twitter.com/en/rules-and-policies/parody-account-policy">Twitter rules on parody accounts</a>. For example, the Twitter bio for the bot should indicate:</p>
<ul>
<li>It&rsquo;s posting AI-generated tweets, made with GPT-2.</li>
<li>It&rsquo;s human-curated (or not).</li>
<li>The Twitter account of who maintains the bot.</li>
<li>The Twitter account(s) the bot is parodying / model is finetuned upon.</li>
</ul>
<p>Additionally, to avoid impersonation, the full name of the Twitter account should not be a verbatim match to the person being parodied (e.g. &ldquo;<em>X</em> but AI&rdquo; is fine), and the profile picture should be visually distinct from the human (e.g. my bots have a black-and-white profile picture). I would also not recommend making bots of people who are more newsworthy to avoid accusations of impersonation (e.g. do not make bots of politicians, <em>especially</em> <a href="https://twitter.com/realDonaldTrump">Donald Trump</a>).</p>
<p>There is still a lot of work that can be done in optimizing Twitter bots, both in terms of generated tweet quality and in ironing out the ethical logistics of maintaining an AI bot account. <strong>I do not believe that AI text-generating bot Twitter accounts will obsolete human Twitter accounts</strong>. It&rsquo;s a different <em>flavor</em> of comedy; not better, not worse. But there&rsquo;s still a lot that can be done to both expand and control the creativity of these Twitter bots, and I have a few active ideas in the pipeline to implement.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Visualizing Airline Flight Characteristics Between SFO and JFK</title>
      <link>https://minimaxir.com/2019/10/sfo-jfk-flights/</link>
      <pubDate>Wed, 23 Oct 2019 09:00:00 -0700</pubDate>
      <guid>https://minimaxir.com/2019/10/sfo-jfk-flights/</guid>
      <description>Box plots, when used correctly, can be a very fun way to visualize big data.</description>
      <content:encoded><![CDATA[<p>In March, <a href="https://cloud.google.com">Google Compute Platform</a> developer advocate <a href="https://twitter.com/felipehoffa">Felipe Hoffa</a> made a tweet about airline flight data from San Francisco International Airport (SFO) to Seattle-Tacoma International Airport (SEA):</p>
<blockquote class="twitter-tweet">
  <a href="https://twitter.com/felipehoffa/status/1111050585120206848"></a>
</blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

<figure>

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

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

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

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

<figure>

    <img loading="lazy" srcset="/2019/10/sfo-jfk-flights/jfk_sfo_departure_delay_nowhiskers_hu_1fecf180ed6a5feb.webp 320w,/2019/10/sfo-jfk-flights/jfk_sfo_departure_delay_nowhiskers_hu_626df458859e27b7.webp 768w,/2019/10/sfo-jfk-flights/jfk_sfo_departure_delay_nowhiskers_hu_58e7e7ba605d269e.webp 1024w,/2019/10/sfo-jfk-flights/jfk_sfo_departure_delay_nowhiskers.png 1800w" src="jfk_sfo_departure_delay_nowhiskers.png"/> 
</figure>
</p>
<p>A negative delay implies the flight leaves early, so we can conclude on average, flights leave slightly earlier than the stated departure time. Even without the whiskers, we can see major spikes at the 75th percentile level for summer months, and said spikes were especially bad in 2017 for both airports.</p>
<p>These box plots are only an <a href="https://en.wikipedia.org/wiki/Exploratory_data_analysis">exploratory data analysis</a>. Determining the <em>cause</em> of changes in these flight metrics is difficult even for experts (I am definitely not an expert!) and many not even be possible to determine from publicly-available data.</p>
<p>But there are still other fun things that can be done with the airline flight data, such as faceting airline trends by time and the inclusion of other airports, which is <a href="https://twitter.com/minimaxir/status/1115261670153048065"><em>interesting</em></a>.</p>
<hr>
<p><em>You can view the BigQuery queries used to get the data, plus the R and ggplot2 used to create the data visualizations, in <a href="http://minimaxir.com/notebooks/sfo-jfk-flights/">this R Notebook</a>. You can also view the images/code used for this post in <a href="https://github.com/minimaxir/sfo-jfk-flights">this GitHub repository</a></em>.</p>
<p><em>You are free to use the data visualizations from this article however you wish, but it would be greatly appreciated if proper attribution is given to this article and/or myself!</em></p>
]]></content:encoded>
    </item>
    <item>
      <title>Experiments with Making Convincing AI-Generated Fake News</title>
      <link>https://minimaxir.com/2019/09/ctrl-fake-news/</link>
      <pubDate>Mon, 30 Sep 2019 08:00:00 -0700</pubDate>
      <guid>https://minimaxir.com/2019/09/ctrl-fake-news/</guid>
      <description>Can the CTRL model create the “fake news” OpenAI was concerned about? Let&amp;rsquo;s put it to the test.</description>
      <content:encoded><![CDATA[<p><span><style>
blockquote {
padding-right: 1.25em !important;
}
</style></span></p>
<figure>

    <img loading="lazy" srcset="/2019/09/ctrl-fake-news/ctrl_demo_ani_hu_86f5f0c7fcd30101.webp 320w,/2019/09/ctrl-fake-news/ctrl_demo_ani_hu_40bd66762dad736e.webp 768w,/2019/09/ctrl-fake-news/ctrl_demo_ani.gif 802w" src="ctrl_demo_ani.gif"/> 
</figure>

<p>When <a href="https://openai.com">OpenAI</a> announced <a href="https://openai.com/blog/better-language-models/">GPT-2</a>, a robust text-generating AI model, they explicitly only released smaller, less robust versions of the model out of fear that the large model could be used to generate fake news. However, since OpenAI described most of the technical decisions needed to create the model <a href="https://d4mucfpksywv.cloudfront.net/better-language-models/language_models_are_unsupervised_multitask_learners.pdf">in the corresponding paper</a>, it would be possible for others to create their own text generating Transformer models, and maybe even <em>improve</em> on GPT-2 (with a sufficient budget!).</p>
<p>In September, the <a href="https://www.salesforce.com">Salesforce</a> AI team released <a href="https://github.com/salesforce/ctrl">CTRL</a>, a Transformer-based text generating model with a twist; the model can generate text from specified domains by passing <strong>control codes</strong> to the model. What caught my interest was a demo of domain style transfer in the <a href="https://arxiv.org/abs/1909.05858">CTRL paper</a>:</p>
<figure>

    <img loading="lazy" srcset="/2019/09/ctrl-fake-news/ctrl_paper_hu_e4ef767ee7d9120b.webp 320w,/2019/09/ctrl-fake-news/ctrl_paper_hu_671c32cfb7fedff7.webp 768w,/2019/09/ctrl-fake-news/ctrl_paper.jpg 864w" src="ctrl_paper.jpg"/> 
</figure>

<p>If the model is that robust to minor URL changes, what happens when you give it URLs that blatantly do not exist? Can the CTRL model create the &ldquo;fake news&rdquo; OpenAI was concerned about? Let&rsquo;s put it to the test.</p>
<h2 id="an-overview-of-ctrl">An Overview of CTRL</h2>
<p>I&rsquo;ve <a href="https://github.com/minimaxir/ctrl-gce">written a guide + scripts</a> to setting the base CTRL model as cheaply as possible on Google Compute Engine with just a few commands. Additionally, the CTRL team has released a <a href="https://colab.research.google.com/drive/1hVveBQShDru1Mjnhe4C21uQv4A2eH1tV">free Colaboratory Notebook</a> which sets up and runs the CTRL model; however, the model is <em>so large</em> it won&rsquo;t fit into the memory of traditional GPUs, so the notebook does a trick to shrink it a bit which may impact generation performance.</p>
<p>Like GPT-2, CTRL has a <a href="https://ai.googleblog.com/2017/08/transformer-novel-neural-network.html">Transformer</a> architecture based on <a href="https://www.tensorflow.org">TensorFlow</a> and uses <a href="https://en.wikipedia.org/wiki/Byte_pair_encoding">byte pair encodings</a> as its inputs and outputs, which are then decoded into readable text. CTRL has notable performance improvements as it&rsquo;s trained on <em>three times as much data as GPT-2</em>, including an <a href="https://github.com/jcpeterson/openwebtext">open-sourced clone</a> of GPT-2&rsquo;s original dataset. And of course, it&rsquo;s larger (1.6B hyperparameters) compared to the currently public GPT-2 (774M hyperparameters), which has significant effects on text quality.</p>
<p>Most importantly, CTRL <em>requires</em> a control code if you want to generate text, which allows for more deterministic output compared to GPT-2/<a href="https://talktotransformer.com">TalkToTransformer</a>. There are several fun control codes, such as <code>Questions</code> if you want to ask the AI a question, or <code>Reviews</code> if you want the AI to generate an <a href="https://www.amazon.com">Amazon</a> review. For this, we&rsquo;ll only look at the <code>Links</code> control code, which lets you provide a URL and/or a prompt for text generation.</p>
<p>As the example from the paper shows, URLs contain a surprising amount of metadata. For example, let&rsquo;s consider a <a href="https://www.washingtonpost.com/powerpost/deal-reached-for-whistleblowers-testimony-house-intelligence-chairman-says/2019/09/29/01cade60-e2d1-11e9-b403-f738899982d2_story.html">random Washington Post URL</a>: <code>https://www.washingtonpost.com/powerpost/deal-reached-for-whistleblowers-testimony-house-intelligence-chairman-says/2019/09/29/01cade60-e2d1-11e9-b403-f738899982d2_story.html</code></p>
<p>There&rsquo;s month/day/year information (<code>2019/09/29</code>), a category (<code>powerpost</code>), and a <a href="https://en.wikipedia.org/wiki/Clean_URL#Slug">URL slug</a> (<code>deal-reached-for-whistleblowers-testimony-house-intelligence-chairman-says</code>), typically present for SEO reasons, but in this case provides strong hints to the underlying content. The Transformer architecture is <em>surprisingly</em> effective at extracting all this metadata, and using it to generate appropriate text.</p>
<h2 id="examples-of-fake-news">Examples of Fake News</h2>
<p>All of the following examples are generated using the full model with the default settings: 256 token length and a temperature of 0 (i.e. the model always selects the next token with the highest probability). I manually truncate the output once it finishes, but other than that, <strong>these AI-generated articles are not edited</strong>.</p>
<p>Let&rsquo;s address the elephant in the room: when people think of &ldquo;fake news&rdquo;, they think of President Donald Trump, who effectively codified the modern usage of the term. So let&rsquo;s generate a fake news story about him with an innocuous topic rarely associated with him. For example, the video gaming phenomenon <a href="https://www.epicgames.com/fortnite/home">Fortnite</a>. All that&rsquo;s needed is to tweak the slug (and optionally the date) in the earlier WaPo URL a bit and prepend the corresponding control code, resulting in <code>Links https://www.washingtonpost.com/powerpost/trump-likes-fortnite/2019/09/24/</code>. Feed it to the model, and behold!</p>
<blockquote>
<p>Trump likes Fortnite</p>
<p>President Trump, who has been known to play video games for hours at a time, is now playing the popular game “Fortnite” on his phone.</p>
<p>The president was spotted watching the online battle royale game, which pits players against each other in an attempt to kill their opponents, while he sat down with House and Senate leaders Tuesday afternoon.</p>
<p>“I like that it’s not just about shooting people but also having fun doing it,” said Trump.</p>
<p>He then asked if anyone had played it before.</p>
<p>“You know what, I don’t think I’ve ever seen that,” replied Rep. Steve Scalise, R-La.</p>
<p>A White House official told reporters later Wednesday afternoon that Trump did indeed watch the game.</p>
<p>“He watched it, yeah,” the official said.</p>
</blockquote>
<p>The generated article is very realistic and follows the style of a typical WaPo article (lede, introduction, body, support, etc.), with proper descriptions of Trump and Fortnite, plus a story that matches the headline. Like GPT-2, there are redundancies in the generation (at the end), but it&rsquo;s not something most readers would find suspicious in a casual read.</p>
<p>Let&rsquo;s tweak things a little. Instead of using <code>likes</code> in the slug, what if we use <code>despises</code>, so <code>trump-despises-fortnite</code>. How well does the model adapt?</p>
<blockquote>
<p>Trump despises Fortnite</p>
<p>The president of the United States has a new favorite video game: “Fortnite.”</p>
<p>President Trump, who is known for his love of fast food and beer, was reportedly so excited about playing this game that he called it “the greatest thing in the world.”</p>
<p>But according to an Axios report, Trump’s enthusiasm waned after learning how much time players spend on it.</p>
<p>“He said it’s not even close to being as good as ‘Candy Crush,’ ” one source told the news outlet.</p>
<p>In fact, the report said, Trump had been watching videos online while eating dinner with Japanese Prime Minister Shinzo Abe at Mar-a-Lago.</p>
<p>According to Axios, Trump also complained about the game’s difficulty level — which can be adjusted by spending real money or virtual currency — saying it wasn’t challenging enough.</p>
<p>“I don’t know why they made it hard,” Trump said.</p>
<p>He then went off topic when asked if he’d play again.</p>
<p>“No, I won’t.”</p>
<p>A White House official later clarified that Trump didn’t say he wouldn’t play, but rather that he’s “not into it.”</p>
</blockquote>
<p>It adapts very well to the change in tone (even with a rhetorical lede!).</p>
<p>Fake news doesn&rsquo;t have to be politically-based. Such news in the financial sector could cause just as much chaos. What if it was reported that <a href="https://www.apple.com">Apple</a>, one of the largest companies in the world, went bankrupt? Put in <code>Links https://www.theverge.com/2019/2/24/20882491/apple-has-filed-for-bankruptcy</code>:</p>
<blockquote>
<p>Apple has filed for bankruptcy.</p>
<p>The company that made the iPhone and iPad is filing for bankruptcy, according to a report from Bloomberg.</p>
<p>According to Bloomberg News, Apple&rsquo;s board of directors voted unanimously on Friday morning in favor of an emergency plan designed to keep it afloat until its next quarterly earnings report.</p>
<p>In order to avoid paying more than $128 million in back taxes, Apple will have to sell off assets like its Beats headphone business, which was valued at about $1 billion when it went public last year.</p>
<p>&ldquo;Apple said today that it plans to file for Chapter 11 protection under US Bankruptcy Court in Delaware this week,&rdquo; Bloomberg reports.</p>
<p>A source familiar with the matter told Reuters earlier this week that Apple had been considering selling some or all of its music streaming service iTunes Music Store as part of a restructuring plan.</p>
<p>But the news comes just days after reports surfaced suggesting that Apple would be forced into liquidation.</p>
<p>Last week, Bloomberg reported that Apple could soon announce layoffs if sales don&rsquo;t pick up soon.</p>
<p>&ldquo;We are working hard every day to deliver great products and services to our customers around the world,&rdquo; Tim Cook, CEO of Apple, wrote in a letter to employees today.</p>
</blockquote>
<p>Again, the model incorporates both bankruptcy terminology and Apple-based entities. There are minor factual errors (Beats is not public, Apple is not incorporated in Delaware), but casual readers would likely scan over that.</p>
<p>Science is also a field with lots of fake discoveries. What if AI reported that <a href="https://en.wikipedia.org/wiki/P_versus_NP_problem">P versus NP</a>, a very famous problem in computer science, was finally solved? Let&rsquo;s try <code>Links https://techcrunch.com/2029/09/24/mit-proves-p-equals-np</code>:</p>
<blockquote>
<p>MIT proves P equals NP</p>
<p>The University of Massachusetts Amherst has proven that the problem posed by factoring large numbers is NP-complete.</p>
<p>In a paper published in Nature, researchers at MIT’s Computer Science and Artificial Intelligence Laboratory (CSAIL) have shown how to factor an integer using only two numbers, one for each digit — which means it takes just three operations on your brain.</p>
<p>This isn’t quite as impressive as solving the problem, but it’s still pretty impressive.</p>
<p>“Given any number n, we can find its product n times with polynomial time if there are no factors involved,” said lead author David DeWitt, who worked on this project while he was a graduate student there.</p>
<p>But what about factorizing larger numbers?</p>
<p>“We showed you could do it, but not without some extra effort,” DeWitt said.</p>
<p>To prove this, they used a technique called Monte Carlo integration, where they simulated millions or billions of different inputs from thousands of computers running various versions of Java.</p>
<p>They then ran their algorithm through all possible combinations of input values until it found the best one.</p>
<p>“The result shows that even though our system doesn’t solve every single case, it does get close enough to be useful.”</p>
</blockquote>
<p>This instance is less factually correct (and it&rsquo;s clickbaity!), but the topic behind the generated text is <em>complicated</em> enough that only domain experts would be able to tell it&rsquo;s not internally consistent.</p>
<p>It&rsquo;s also worth pointing out now that <em>the majority of the text generated so far is unique</em>, and CTRL is not cheating by just outputting text from the source datasets. Even though Monte Carlo integration is <a href="https://en.wikipedia.org/wiki/Monte_Carlo_integration">indeed a real thing</a> that&rsquo;s relatively obscure, Googling that description of it fails to turn up results.</p>
<p>Lastly, fake news doesn&rsquo;t have to be depressing or sow discord. What about <em>wholesome</em> fake news? Such as&hellip;the return of beloved television series <a href="https://en.wikipedia.org/wiki/Firefly_%28TV_series%29">Firefly</a>? One <code>Links https://www.hollywoodreporter.com/live-feed/fox-announces-firefly-season-2-revival-original-cast-returning-12343245</code> later:</p>
<blockquote>
<p>Fox announces Firefly Season 2 revival, original cast returning</p>
<p>The network has announced that the second season of Joss Whedon’s cult sci-fi series will return for a new run in 2018.</p>
<p>“Fans have been asking us to bring back Firefly, and we couldn’t be more thrilled about this opportunity,” said Chris Albrecht, president of programming at Fox. “We are excited to continue our relationship with Joss, who is one of the most creative minds working today on television, as well as his amazing cast, which includes Nathan Fillion, Summer Glau, Alan Tudyk, Adam Baldwin, Sean Maher, Adrianne Palicki, Tricia Helfer, Jewel Staite, Michael Trucco, and many more.”</p>
<p>In addition to Whedon himself, executive producers include Damon Lindelof, Maurissa Tancharoen, Vince Gilligan, David Fury, Jeffrey Bell, Greg Plageman, Scott M. Gimple, Heather Kadin, Andrew Chambliss, John Rogers, and Ron Moore.</p>
<p>“The show was an instant hit when it debuted over 20 years ago, but its popularity only grew after the success of ‘Serenity,’ so we’re very pleased to welcome fans into another chapter of their lives,” added Feige.</p>
</blockquote>
<p>That is a <em>very</em> stacked cast and crew, all of which (besides the original Firefly members) have acted/worked on sci-fi television series. The only major factual errors are that Chris Albrecht was at STARZ, not Fox, and Feige, presumably Kevin Feige of Marvel Studios, is not mentioned previously in the generated article.</p>
<p>I know I&rsquo;ll get criticism for highlighting a potentially dangerous application of AI text generation. My perspective is that it&rsquo;s important to know what such tools are <em>capable</em> of doing in order to more easily recognize fake news. The real problem with fake news isn&rsquo;t the text itself: it&rsquo;s the <em>distribution</em> of the news on social media like <a href="http://www.facebook.com">Facebook</a> and <a href="https://twitter.com">Twitter</a>, where the platforms not only <em>incentivize</em> it, but also fail to sufficiently punish deliberate, repeat offenders. It&rsquo;s why journalism and awareness of fake news is extremely important.</p>
<p>Some might comment &ldquo;these generated texts aren&rsquo;t convincing at all!&rdquo;, but keep in mind that&rsquo;s because the headline says upfront that they&rsquo;re fake. Would you be able to identify it as a fake if a respected source impulsively tweeted it?</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
