<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://christophersoria.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://christophersoria.com/" rel="alternate" type="text/html" /><updated>2026-10-10T13:54:18-07:00</updated><id>https://christophersoria.com/feed.xml</id><title type="html">Chris Soria</title><subtitle>PhD Candidate at UC Berkeley, Demography</subtitle><author><name>Contact Information</name><email>chrissoria@berkeley.edu</email></author><entry><title type="html">Presenting LLM Ensembles at PAPOR</title><link href="https://christophersoria.com/posts/2026/08/papor-2026-llm-ensembles/" rel="alternate" type="text/html" title="Presenting LLM Ensembles at PAPOR" /><published>2026-08-22T00:00:00-07:00</published><updated>2026-08-22T00:00:00-07:00</updated><id>https://christophersoria.com/posts/2026/08/papor-2026-llm-ensembles</id><content type="html" xml:base="https://christophersoria.com/posts/2026/08/papor-2026-llm-ensembles/"><![CDATA[<p><img src="/images/catllm_ensemble.png" alt="CatLLM ensemble of multiple LLM providers" /></p>

<p>On August 21 I presented “Using LLM Ensembles to Improve Classification of Open-Ended Survey Responses” at the <a href="https://www.papor.org/">Pacific Chapter of AAPOR (PAPOR)</a>, hosted by <a href="https://www.kff.org/">KFF</a>. It was a pleasure to present alongside Kevin Collins of <a href="https://www.survey160.com/">Survey 160</a>, Meredith Massey of the <a href="https://www.cdc.gov/nchs/">National Center for Health Statistics</a>, and Anagha Srivatsav of Miravoice. The full abstract is on the <a href="/talks/2026-08-21-talk-20">talk page</a>.</p>

<p>The short version: large language models are increasingly used to code open-ended survey responses, but they systematically over-classify, assigning categories too liberally on ambiguous cases and producing high sensitivity with low precision. Survey methodologists have long known that aggregating multiple noisy annotators beats any single annotator, so the talk asked whether the same principle transfers to LLMs. Across four open-ended survey questions with human-coded ground truth (3,208 responses) and 16 models spanning three cost tiers and six providers, unanimous voting across models corrects the problem directly: on the most ambiguous categories, the false positive rate drops from 50% to 3% and precision triples. The benefit concentrates exactly where over-classification is worst, on subjectively ambiguous categories with fuzzy boundaries, and it replicates across three independent datasets.</p>

<p>The part I most wanted a room of survey researchers to hear is what drives the correction. It is decorrelated errors, and the most reliable way to get them is cross-provider diversity: models from different providers err differently on ambiguous cases, and consensus filters out the idiosyncratic false positives. Temperature variation and scaling up within a single model family do not reliably do the same. As few as three diverse lower-tier models are enough to beat GPT-5. If you only take one thing from the talk, it is that investing in a few different models is more effective than investing in one expensive model.</p>

<p>The work is written up in <a href="/publication/2026-06-04-llm-ensembles-survey-coding">Model Diversity Over Model Size</a> (preprint on <a href="https://osf.io/preprints/socarxiv/er6mz_v1">SocArXiv</a>, currently revise and resubmit at <em>Public Opinion Quarterly</em>), and unanimous multi-model ensembling ships as a default in <a href="https://github.com/chrissoria/cat-llm">CatLLM</a>.</p>

<p>Thank you to the PAPOR organizers and to KFF for hosting. It was a great group to be in conversation with, and I hope to be back.</p>]]></content><author><name>Contact Information</name><email>chrissoria@berkeley.edu</email></author><category term="CatLLM" /><category term="Large Language Models" /><category term="Survey Research" /><category term="Conferences" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">100,000 Downloads: What CatLLM Has Built So Far</title><link href="https://christophersoria.com/posts/2026/08/catllm-100k-downloads/" rel="alternate" type="text/html" title="100,000 Downloads: What CatLLM Has Built So Far" /><published>2026-08-04T00:00:00-07:00</published><updated>2026-08-04T00:00:00-07:00</updated><id>https://christophersoria.com/posts/2026/08/catllm-100k-downloads</id><content type="html" xml:base="https://christophersoria.com/posts/2026/08/catllm-100k-downloads/"><![CDATA[<p><img src="/images/wide_cat_llm.png" alt="CatLLM logo" /></p>

<p><a href="https://pypi.org/project/cat-llm/">CatLLM</a> has passed <strong>100,000 downloads on PyPI</strong>. I started it because I had a column of open-ended survey responses and no good way to code them at scale; I did not expect it to end up anywhere near six figures.</p>

<p>I want to mark the number, but the number is not really the interesting part. Downloads measure adoption, not whether a tool is any good. So this post is about the two things I think actually matter: what the defaults are grounded in, and what people have built with it.</p>

<h2 id="the-defaults-are-empirical-claims-not-preferences">The Defaults Are Empirical Claims, Not Preferences</h2>

<p>Most LLM classification tooling ships with defaults chosen because they seemed reasonable. CatLLM’s defaults are the output of benchmarking studies against human-coded ground truth, and every one of them exists because the alternative measurably failed.</p>

<p>The core finding across those studies is that <strong>LLMs systematically over-classify.</strong> They assign categories too liberally on ambiguous cases, producing high sensitivity and much lower precision, which means default configurations can substantially overstate how common a theme is in your data. That single failure mode is what most of CatLLM’s defaults are designed to correct:</p>

<ul>
  <li><strong>Verbose category definitions with explicit inclusion and exclusion criteria.</strong> Vague category labels are where over-classification concentrates.</li>
  <li><strong>Unanimous multi-model ensembling.</strong> Requiring cross-provider agreement drops the false positive rate on the most ambiguous categories from 50% to 3% and triples precision.</li>
  <li><strong>An automatic “Other” escape valve.</strong> Models forced to choose among your categories will force-fit responses that belong in none of them.</li>
  <li><strong>Advanced prompting strategies off by default.</strong> They showed no reliable benefit on these tasks, so they are opt-in rather than assumed.</li>
</ul>

<p>The package is also peer-reviewed. It went through review at the <em>Journal of Open Source Software</em>, and the empirical validation behind the defaults is written up separately and under review at disciplinary journals. That is a deliberately slower path than shipping a package and writing a README, and I think it is the right one for tooling that produces variables people then run regressions on.</p>

<h2 id="the-papers">The Papers</h2>

<p>Five papers so far, all of which either document CatLLM or exist because of the validation work behind it. All preprints are open access.</p>

<p><strong>Peer-reviewed:</strong></p>

<ul>
  <li>Soria C. <a href="https://doi.org/10.21105/joss.09678">CatLLM: A Python package for Generating, Assigning, and Scoring Open-Ended Survey Data and Images</a>. <em>Journal of Open Source Software.</em> 2026. doi:10.21105/joss.09678</li>
</ul>

<p><strong>Preprints under review:</strong></p>

<ul>
  <li>
    <p>Soria C. <a href="https://osf.io/preprints/socarxiv/gjvcf_v1">Scaling Open-Ended Survey Coding: An LLM Pipeline Where Definitions Do the Heavy Lifting</a>. <em>SocArXiv.</em> 2026.
<br /><em>21 LLMs, three capability tiers, six providers, four survey questions, benchmarked against sociologist-coded ground truth. This is the study the defaults come from. Under review at the Journal of Computational Social Science.</em></p>
  </li>
  <li>
    <p>Soria C. <a href="https://osf.io/preprints/socarxiv/85kyd_v1">High Agreement, Different Stories: How LLM Classifiers Reshape Demographic Patterns in Survey Data</a>. <em>SocArXiv.</em> 2026.
<br /><em>Eight LLMs against human annotators on 3,200 responses and over 19,000 coding decisions. Models hit 82–97% per-category agreement while reproducing the full human label set for fewer than 60% of responses, and the gap is not evenly distributed across demographic groups. Revise and resubmit at the Journal of Survey Statistics and Methodology.</em></p>
  </li>
  <li>
    <p>Soria C. <a href="https://osf.io/preprints/socarxiv/er6mz_v1">Model Diversity Over Model Size: Unanimous LLM Ensembles Correct Over-Classification in Survey Coding</a>. <em>SocArXiv.</em> 2026.
<br /><em>Sixteen models across three cost tiers and six providers. Cross-provider diversity is the active ingredient, not model size or temperature; three diverse cheap models reliably beat GPT-5. Revise and resubmit at Public Opinion Quarterly.</em></p>
  </li>
  <li>
    <p>Soria C. <a href="https://osf.io/preprints/socarxiv/5g34x_v1">What Does Frontier Access Buy? Benchmarking LLM Access Tiers and the Reproducibility Case for Open Weights in Survey Coding</a>. <em>SocArXiv.</em> 2026.
<br /><em>Eighteen LLMs across four access tiers on five tasks. The flagship tier’s accuracy margin over cheaper models is statistically indistinguishable from zero despite costing 1.3–6× more per token, and the largest open-weight models match both proprietary tiers on cell-level agreement. Under review at Survey Research Methods.</em></p>
  </li>
</ul>

<p>The practical upshot of the last two, if you only take one thing from this section: you probably do not need the expensive model. You need a few different ones.</p>

<h2 id="it-stopped-being-a-survey-tool">It Stopped Being a Survey Tool</h2>

<p>I built CatLLM for open-ended survey responses. That is still the use case I understand best and the one the validation studies target. But the same three-stage pipeline (explore, extract, classify) turns out to be indifferent to where the text came from, and people have taken it well outside survey methodology.</p>

<p>Some of what it is being used for now:</p>

<ul>
  <li><strong>Local policy.</strong> Municipal ordinances are dense, high-volume, and almost entirely unanalyzed. I used the pipeline to classify 200 recent ordinances each from San Diego and San Francisco against a policy taxonomy and a political lean scheme; you can read <a href="/posts/2026/03/catpol-ordinance-analysis/">what came out of that</a>.</li>
  <li><strong>Social media.</strong> People are coding their own posting histories and public platform data. I wrote up analyses of <a href="/posts/2026/03/catvader-bluesky-analysis/">Bluesky</a> and <a href="/posts/2026/03/catvader-threads-analysis/">Threads</a> content using the same machinery.</li>
  <li><strong>Academic literature.</strong> Screening and coding papers at a volume that makes manual abstract review impractical.</li>
  <li><strong>Documents and images.</strong> PDF and image classification for anything that lives in a scanned archive rather than a clean CSV.</li>
</ul>

<p>That range is why the ecosystem split into domain packages (<code class="language-plaintext highlighter-rouge">cat-pol</code> for political text, <code class="language-plaintext highlighter-rouge">cat-vader</code> for social media, <code class="language-plaintext highlighter-rouge">cat-ademic</code> for academic writing, <code class="language-plaintext highlighter-rouge">cat-web</code>, <code class="language-plaintext highlighter-rouge">cat-cog</code>, <code class="language-plaintext highlighter-rouge">cat-survey</code>) on a shared <code class="language-plaintext highlighter-rouge">cat-stack</code> engine. It is also available <a href="/posts/2026/05/cat-llm-r-packages/">in R</a>, in Stata, as a <a href="/posts/2026/05/catllm-mac-desktop-app/">Mac desktop app</a>, as a <a href="/posts/2026/01/catllm-web-app/">web app</a>, and <a href="/posts/2026/03/catllm-claude-code/">through Claude Code</a> for researchers who would rather not write any code at all.</p>

<h2 id="if-youve-used-it-id-like-to-know">If You’ve Used It, I’d Like to Know</h2>

<p>Here is the actual ask.</p>

<p>PyPI tells me the package has been downloaded. It tells me nothing about who, for what, or whether the results held up. I have no way to find the work CatLLM has contributed to unless someone tells me about it.</p>

<p><strong>If you have used CatLLM in a paper, a preprint, a thesis, a dissertation, a class assignment, a report, or a blog post, I would genuinely like to hear about it.</strong> I want to maintain a public list of work that used the package, and I want to know which parts held up under conditions I never tested. I am especially interested in:</p>

<ul>
  <li>Non-English text and non-U.S. contexts, which the validation studies barely cover.</li>
  <li>Domains I have not benchmarked at all: clinical notes, interview transcripts, historical documents, customer feedback.</li>
  <li>Cases where it did <em>not</em> work. Negative results are more useful to me than success stories, and they will shape the defaults for the next version.</li>
</ul>

<p>Reach me at <strong>chrissoria@berkeley.edu</strong>, or open a thread on the <a href="https://github.com/chrissoria/cat-llm">GitHub repo</a>. No formality needed. A link and a sentence is plenty.</p>

<h2 id="install-it">Install It</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>cat-llm
</code></pre></div></div>

<p>Everything is open source under GPL-3.0 and it will stay that way. The full pipeline runs on free HuggingFace models or locally through <a href="https://ollama.com">Ollama</a> at zero API cost, and, per the access-tier study, local open-weight models are a real option rather than a consolation prize. There is no paid tier and there is not going to be one.</p>

<p>If you use it in published work, the citation is:</p>

<blockquote>
  <p>Soria C. CatLLM: A Python package for Generating, Assigning, and Scoring Open-Ended Survey Data and Images. <em>Journal of Open Source Software.</em> 2026. doi:<a href="https://doi.org/10.21105/joss.09678">10.21105/joss.09678</a></p>
</blockquote>

<p>Thanks to everyone who filed an issue, reported a bug, or asked for a provider I had not added yet. A hundred thousand downloads is a nice round number, but the issues are what actually made the package better.</p>]]></content><author><name>Contact Information</name><email>chrissoria@berkeley.edu</email></author><category term="CatLLM" /><category term="Large Language Models" /><category term="Text Classification" /><category term="Survey Research" /><category term="Open Source" /><category term="Reproducibility" /><category term="Computational Social Science" /><summary type="html"><![CDATA[CatLLM has passed 100,000 downloads on PyPI. A look at the peer-reviewed validation behind its defaults, the papers it has produced, and the non-survey research people are using it for.]]></summary></entry><entry><title type="html">Presenting CADAS at AAIC 2026 in London</title><link href="https://christophersoria.com/posts/2026/08/aaic-2026-london/" rel="alternate" type="text/html" title="Presenting CADAS at AAIC 2026 in London" /><published>2026-08-03T00:00:00-07:00</published><updated>2026-08-03T00:00:00-07:00</updated><id>https://christophersoria.com/posts/2026/08/aaic-2026-london</id><content type="html" xml:base="https://christophersoria.com/posts/2026/08/aaic-2026-london/"><![CDATA[<p><img src="/images/aaic_2026_consensus.png" alt="Country classification rates and disagreement with international consensus" /></p>

<p>On July 12 I presented at the Gateway/HCAP consensus session at the Alzheimer’s Association International Conference (AAIC) in London, sharing work from the <a href="/publication/2025-08-29-cadas-protocol">Caribbean American Dementia and Aging Study (CADAS)</a> on a multinational framework for online cognitive impairment diagnosis.</p>

<p>The short version: twenty-seven clinicians from five countries (the Dominican Republic, Mexico, Puerto Rico, the United States, and India) independently classified 300 older adults using CADAS survey data, applying a structured 10-question NIA-AA diagnostic algorithm through a web-based consensus platform. Each case was reviewed by at least three clinicians per country team, cases with within-country disagreement went to moderated consensus discussion, and international consensus was defined by majority rule across all five teams. The figure above gives a taste of the results: the country teams classified dementia at noticeably different rates, and their disagreement with the international consensus ranged from 2% (United States) to 10% (India). Getting clinicians on three continents to review the same evidence through one platform is as much a coordination problem as a clinical one, and seeing it work end to end was the most satisfying part of the project.</p>

<p>A huge thank you to all of my coauthors for their contributions, especially <a href="https://sph.berkeley.edu/william-h-dow">William Dow</a>, <a href="https://neurology.wustl.edu/people/jorge-llibre-guerra-md/">Jorge Llibre-Guerra</a>, and <a href="https://dornsife.usc.edu/cesr/profile/jinkook-lee/">Jinkook Lee</a> of the <a href="https://g2aging.org/">Gateway to Global Aging</a> project.</p>

<p>It was a pleasure meeting everyone there. London itself was a highlight (the whole city is like a museum), and I’m looking forward to presenting at AAIC again someday.</p>]]></content><author><name>Contact Information</name><email>chrissoria@berkeley.edu</email></author><category term="Dementia" /><category term="Aging" /><category term="Research" /><category term="Conferences" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">New Preprint: Model Diversity Over Model Size — Unanimous LLM Ensembles Correct Over-Classification in Survey Coding</title><link href="https://christophersoria.com/posts/2026/06/llm-ensembles-survey-coding-preprint/" rel="alternate" type="text/html" title="New Preprint: Model Diversity Over Model Size — Unanimous LLM Ensembles Correct Over-Classification in Survey Coding" /><published>2026-06-04T00:00:00-07:00</published><updated>2026-06-04T00:00:00-07:00</updated><id>https://christophersoria.com/posts/2026/06/llm-ensembles-survey-coding-preprint</id><content type="html" xml:base="https://christophersoria.com/posts/2026/06/llm-ensembles-survey-coding-preprint/"><![CDATA[<p>I have a new preprint out on SocArXiv: <a href="https://osf.io/preprints/socarxiv/er6mz_v1">Model Diversity Over Model Size: Unanimous LLM Ensembles Correct Over-Classification in Survey Coding</a>. It’s a deep dive into one finding flagged in the <a href="https://osf.io/preprints/socarxiv/gjvcf_v1">CatLLM methods paper</a> — that unanimous multi-model ensembling corrects over-classification — asking <em>which</em> ensemble ingredients actually drive the gain and <em>where</em> the gain shows up.</p>

<h2 id="abstract">Abstract</h2>

<p>Large language models are increasingly used to classify open-ended survey responses, but they systematically over-classify, assigning categories too liberally on ambiguous cases and producing high sensitivity but low precision. Drawing on the established principle that aggregating multiple noisy annotators outperforms any single annotator, we test whether ensembles of LLMs can correct this problem. Using four open-ended survey questions with human-coded ground truth (3,208 responses, 6 categories per question), we evaluate ensemble configurations across 16 models spanning three cost tiers and six providers.</p>

<h2 id="key-findings">Key Findings</h2>

<ul>
  <li><strong>Unanimous voting fixes the over-classification problem.</strong> On the most ambiguous categories, the false positive rate drops from 50% to 3% under unanimous agreement, and precision triples.</li>
  <li><strong>The gain concentrates where over-classification is worst.</strong> Subjectively ambiguous categories with fuzzy boundaries see large improvements; categories with clear criteria show no benefit. The pattern replicates across three independent datasets (UCNets, GoEmotions, the British Election Study).</li>
  <li><strong>Cross-provider model diversity is the active ingredient.</strong> Models from different providers make different errors on ambiguous cases, and consensus filters the idiosyncratic false positives. Temperature variation and within-family size scaling contribute essentially nothing.</li>
  <li><strong>Three cheap models can beat one expensive one.</strong> As few as three diverse lower-tier models suffice to reliably exceed GPT-5 on the ambiguous classification tasks where this matters most.</li>
</ul>

<h2 id="implications">Implications</h2>

<p>For the ambiguous classification problems common in open-ended survey research, the well-established annotation principle of multi-coder agreement transfers directly to LLMs: investing in <em>diverse perspectives</em> is more effective than investing in a single expensive model. Practically, this means survey researchers can often skip the frontier-tier subscription and instead orchestrate a small cross-provider ensemble — frequently at a fraction of the cost.</p>

<p><a href="https://osf.io/preprints/socarxiv/er6mz_v1">Read the full preprint on SocArXiv</a> · <a href="https://anonymous.4open.science/r/catllm-replication-A4E2/README.md">Replication materials</a> · <a href="https://catllm.com">CatLLM (the toolkit used to produce the classifications)</a></p>]]></content><author><name>Contact Information</name><email>chrissoria@berkeley.edu</email></author><category term="Large Language Models" /><category term="Survey Data" /><category term="Categorization" /><category term="Ensembles" /><category term="Unanimous Voting" /><category term="Preprint" /><summary type="html"><![CDATA[I have a new preprint out on SocArXiv: Model Diversity Over Model Size: Unanimous LLM Ensembles Correct Over-Classification in Survey Coding. It’s a deep dive into one finding flagged in the CatLLM methods paper — that unanimous multi-model ensembling corrects over-classification — asking which ensemble ingredients actually drive the gain and where the gain shows up.]]></summary></entry><entry><title type="html">New Preprint: High Agreement, Different Stories — How LLM Classifiers Reshape Demographic Patterns in Survey Data</title><link href="https://christophersoria.com/posts/2026/06/llm-classifiers-demographic-patterns-preprint/" rel="alternate" type="text/html" title="New Preprint: High Agreement, Different Stories — How LLM Classifiers Reshape Demographic Patterns in Survey Data" /><published>2026-06-02T00:00:00-07:00</published><updated>2026-06-02T00:00:00-07:00</updated><id>https://christophersoria.com/posts/2026/06/llm-classifiers-demographic-patterns-preprint</id><content type="html" xml:base="https://christophersoria.com/posts/2026/06/llm-classifiers-demographic-patterns-preprint/"><![CDATA[<p>I have a new preprint out on SocArXiv: <a href="https://osf.io/preprints/socarxiv/85kyd_v1">High Agreement, Different Stories: How LLM Classifiers Reshape Demographic Patterns in Survey Data</a>. This is a substantially revised version of my earlier preprint, with new analyses centered on how high agreement rates can mask thematic and demographic divergence between LLM and human coders.</p>

<h2 id="abstract">Abstract</h2>

<p>What we learn from open-ended survey data depends on who—or what—does the coding. Large Language Models (LLMs) promise to democratize qualitative analysis, but do high agreement rates translate into equivalent thematic findings? This study compares eight LLMs to human annotators on a multilabel coding task using 3,200 responses from the UC Berkeley Social Networks Study, comprising over 19,000 coding decisions. Although LLM-human reliability does not match human-human reliability overall, LLMs approach human performance on simpler tasks and can serve as useful additional coders for generating consensus labels.</p>

<h2 id="key-findings">Key Findings</h2>

<ul>
  <li>Compared to a gold-standard human consensus, models achieve 82–97% per-category agreement, but macro F1 is lower and response-level similarity is lower still: even the best model reproduces the full human label set for fewer than 60% of responses.</li>
  <li>High agreement masks thematic divergence. Models systematically over-identify themes, assigning 67% more categories per response, especially for categories requiring greater interpretive judgment.</li>
  <li>Models show lower agreement for some demographic groups. These gaps are partly explained by response characteristics such as length, clarity, and atypicality, and some persist after controls, with implications for studies of populations whose response styles diverge from the corpus average.</li>
  <li>At the sample level, models largely preserve the overall thematic narrative: human and model category rankings correlate strongly (pooled Spearman’s ρ = 0.75), and top-performing models achieve approximately 80% directional agreement on demographic patterns.</li>
  <li>Concrete behavioral questions—such as reasons for moving or strategies for making friends—show especially strong alignment, whereas more interpretive questions show greater divergence.</li>
</ul>

<h2 id="implications">Implications</h2>

<p>Systematic over-classification can still shift narratives about how specific groups behave, leading researchers to report patterns that the human gold standard does not support. LLMs can meaningfully reduce coding burden, but for studies sensitive to demographic nuance they are best treated as additional coders contributing to a human-anchored consensus rather than as drop-in replacements.</p>

<p><a href="https://osf.io/preprints/socarxiv/85kyd_v1">Read the full preprint on SocArXiv</a> · <a href="https://osf.io/nqep5/">Project page and code on OSF</a></p>]]></content><author><name>Contact Information</name><email>chrissoria@berkeley.edu</email></author><category term="Large Language Models" /><category term="Survey Data" /><category term="Categorization" /><category term="Demographic Patterns" /><category term="Preprint" /><summary type="html"><![CDATA[I have a new preprint out on SocArXiv: High Agreement, Different Stories: How LLM Classifiers Reshape Demographic Patterns in Survey Data. This is a substantially revised version of my earlier preprint, with new analyses centered on how high agreement rates can mask thematic and demographic divergence between LLM and human coders.]]></summary></entry><entry><title type="html">CatLLM Desktop: A Mac App for Classifying Text Without Writing Code</title><link href="https://christophersoria.com/posts/2026/05/catllm-mac-desktop-app/" rel="alternate" type="text/html" title="CatLLM Desktop: A Mac App for Classifying Text Without Writing Code" /><published>2026-05-25T00:00:00-07:00</published><updated>2026-05-25T00:00:00-07:00</updated><id>https://christophersoria.com/posts/2026/05/catllm-mac-desktop-app</id><content type="html" xml:base="https://christophersoria.com/posts/2026/05/catllm-mac-desktop-app/"><![CDATA[<p><img src="/images/catllm-desktop-screenshot.png" alt="CatLLM desktop app — General Classify view" /></p>

<p><a href="https://catllm.com">CatLLM</a> is an open-source Python and R toolkit for classifying open-ended text with large language models. For survey classification, the defaults are calibrated against the consensus of double-blind coding by sociologists and demographers across multiple datasets — yielding 88% to 99% agreement with human coders across 30+ LLMs depending on the task (<a href="https://osf.io/preprints/socarxiv/gjvcf_v1">Soria 2026</a>). The same engine underlies separate packages for political text, social media, academic papers, and other domains, and the toolkit has been used in several academic research papers.</p>

<p><strong>CatLLM Desktop</strong> is now available. The Mac app is the same <code class="language-plaintext highlighter-rouge">cat-llm</code> pipeline that powers the Python and R libraries, wrapped as a self-contained application — no Python install, no terminal, no reticulate, no API gymnastics. Drag in a CSV or Excel file, choose categories, and download a coded dataset. The <a href="https://huggingface.co/chrissoria/catllm-desktop/resolve/main/CatLLM-3.1.1-arm64.dmg">Apple Silicon <code class="language-plaintext highlighter-rouge">.dmg</code></a> (286 MB) is on Hugging Face today, linked from the <a href="https://catllm.com#download">download section on catllm.com</a>; Intel and Windows builds are planned. On first launch, macOS Gatekeeper will say it can’t check the app — right-click the app in Applications → Open → Open, and you can verify the download against the matching <a href="https://huggingface.co/chrissoria/catllm-desktop/tree/main">SHA-256</a>.</p>

<p>The core workflow is a no-code classify-from-spreadsheet: pick a Domain (General, Survey, and so on) and a Function (Classify, Extract, Explore, Summarize) from the left sidebar, drop in a CSV or XLSX file up to 200 MB, choose the column you want coded, and either type your categories in directly or let the app auto-extract a candidate taxonomy from a sample of the data. Results appear on the right, ready to download as a CSV. The outputs are bitwise the same as what the Python library produces with the same configuration, which means anything coded in the app can be re-run later in code without re-validating the defaults.</p>

<p>Model setup exposes both paths the package supports: cloud API keys (OpenAI, Anthropic, Google) entered once and stored locally on the machine, or local models served by <a href="https://ollama.com">Ollama</a>. The privacy implication matters for survey work — with Ollama, no response text ever leaves the laptop, which is the friction-free path for IRB-bound research, clinical text, or collaborators who can’t legally send respondent data to a vendor. The provider-agnostic engine underneath is the same as in the library, so swapping <code class="language-plaintext highlighter-rouge">gpt-5</code> for <code class="language-plaintext highlighter-rouge">qwen2.5:14b</code> is a dropdown change, not a code change.</p>

<p>The piece that ties this back to the library is the <strong>Deploy</strong> button at the top right: it emits the equivalent Python (and R) script for whatever you configured in the GUI, so a classification run is not a black box. The script is reproducible — commit it to your repo, paste it into a paper’s supplement, or hand it to a co-author who already works in code. That’s the bridge the app is meant to be: collaborators without programming experience produce work that another team member can re-run, audit, and extend. Download the <a href="https://huggingface.co/chrissoria/catllm-desktop/resolve/main/CatLLM-3.1.1-arm64.dmg">Mac app</a>, or reach out at <a href="mailto:ChrisSoria@Berkeley.edu">ChrisSoria@Berkeley.edu</a> if you hit anything weird.</p>]]></content><author><name>Contact Information</name><email>chrissoria@berkeley.edu</email></author><category term="CatLLM" /><category term="Desktop App" /><category term="macOS" /><category term="Apple Silicon" /><category term="Large Language Models" /><category term="Text Classification" /><category term="Survey Data" /><category term="Open Source" /><summary type="html"><![CDATA[CatLLM is now available as a self-contained Mac app. Drag in a CSV, pick categories, get a coded dataset back — the same engine as the Python and R packages, no Python install required.]]></summary></entry><entry><title type="html">CatLLM R Package: Classify Survey Text with LLMs in R</title><link href="https://christophersoria.com/posts/2026/05/cat-llm-r-packages/" rel="alternate" type="text/html" title="CatLLM R Package: Classify Survey Text with LLMs in R" /><published>2026-05-17T00:00:00-07:00</published><updated>2026-05-17T00:00:00-07:00</updated><id>https://christophersoria.com/posts/2026/05/cat-llm-r-packages</id><content type="html" xml:base="https://christophersoria.com/posts/2026/05/cat-llm-r-packages/"><![CDATA[<p><img src="/images/catllm_bw_banner.png" alt="CatLLM R package ecosystem banner" /></p>

<p>The <strong>CatLLM R package</strong> is now live. <a href="https://pypi.org/project/cat-llm/">CatLLM</a> started life as a Python package for classifying open-ended survey responses with LLMs, and it still is, but a lot of the researchers I want to reach work in R, not Python. Asking a sociologist or demographer to spin up a Python project just to categorize a column of text responses is a friction tax most won’t pay.</p>

<p>So I built the R layer. As of this week, <strong>eight R packages</strong> (<code class="language-plaintext highlighter-rouge">cat.stack</code>, <code class="language-plaintext highlighter-rouge">cat.survey</code>, <code class="language-plaintext highlighter-rouge">cat.vader</code>, <code class="language-plaintext highlighter-rouge">cat.ademic</code>, <code class="language-plaintext highlighter-rouge">cat.cog</code>, <code class="language-plaintext highlighter-rouge">cat.pol</code>, <code class="language-plaintext highlighter-rouge">cat.web</code>, and the <code class="language-plaintext highlighter-rouge">cat.llm</code> meta-package) are installable from <a href="https://chrissoria.r-universe.dev">r-universe</a>. They wrap the Python CatLLM ecosystem via <a href="https://rstudio.github.io/reticulate/">reticulate</a>, expose the same <code class="language-plaintext highlighter-rouge">classify()</code>, <code class="language-plaintext highlighter-rouge">extract()</code>, <code class="language-plaintext highlighter-rouge">explore()</code>, and <code class="language-plaintext highlighter-rouge">summarize()</code> API, and return results bitwise-equivalent to the Python equivalents. There is no R-specific logic; it is a thin shim. But it means you can stay in your <code class="language-plaintext highlighter-rouge">tidyverse</code> pipeline.</p>

<h2 id="why-a-catllm-r-package">Why a CatLLM R Package</h2>

<p>A huge share of survey methodology, demography, sociology, political science, and public health work happens in R. The available options for those users have been (1) reticulate the Python package themselves, (2) round-trip data to a separate Python script, or (3) hand-code text by RA. The first is fiddly, the second is annoying, the third does not scale. A native R interface removes all three.</p>

<h2 id="installation">Installation</h2>

<div class="language-r highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">install.packages</span><span class="p">(</span><span class="w">
  </span><span class="s2">"cat.llm"</span><span class="p">,</span><span class="w">
  </span><span class="n">repos</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nf">c</span><span class="p">(</span><span class="s2">"https://chrissoria.r-universe.dev"</span><span class="p">,</span><span class="w">
            </span><span class="s2">"https://cloud.r-project.org"</span><span class="p">)</span><span class="w">
</span><span class="p">)</span><span class="w">

</span><span class="n">library</span><span class="p">(</span><span class="n">cat.llm</span><span class="p">)</span><span class="w">
</span><span class="n">install_cat_stack</span><span class="p">()</span><span class="w">   </span><span class="c1"># one-time Python backend setup</span><span class="w">
</span></code></pre></div></div>

<blockquote>
  <p><strong>Not on CRAN yet.</strong> Install from r-universe for now. The packages are not on CRAN while the API stabilizes. R-universe is set up like an additional repo, so <code class="language-plaintext highlighter-rouge">install.packages()</code> works as usual.</p>
</blockquote>

<blockquote>
  <p><strong>Why the dot in <code class="language-plaintext highlighter-rouge">cat.llm</code>?</strong> R’s convention for multi-word package names uses a dot, like <code class="language-plaintext highlighter-rouge">data.table</code> or <code class="language-plaintext highlighter-rouge">R.utils</code>. The Python equivalents drop the dot (<code class="language-plaintext highlighter-rouge">catllm</code>, <code class="language-plaintext highlighter-rouge">catstack</code>, <code class="language-plaintext highlighter-rouge">catsurvey</code>, and so on). Use the dotted form in <code class="language-plaintext highlighter-rouge">library()</code> and <code class="language-plaintext highlighter-rouge">install.packages()</code>.</p>
</blockquote>

<p><code class="language-plaintext highlighter-rouge">install_cat_stack()</code> runs once. Under the hood it calls <code class="language-plaintext highlighter-rouge">pip install</code> for the Python backend packages and configures reticulate to find them. After that you do not think about Python again.</p>

<h2 id="a-first-classification-with-a-local-llm">A First Classification with a Local LLM</h2>

<p>The simplest way to verify the install is to classify a handful of responses with a local <a href="https://ollama.com">Ollama</a> model. No API key, no cloud calls, no per-token cost.</p>

<p>In a terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ollama pull qwen2.5:14b   <span class="c"># ~9 GB on disk, ~10 GB RAM to run</span>
</code></pre></div></div>

<p>Then in R:</p>

<div class="language-r highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">library</span><span class="p">(</span><span class="n">cat.survey</span><span class="p">)</span><span class="w">

</span><span class="n">responses</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="nf">c</span><span class="p">(</span><span class="w">
  </span><span class="s2">"The new wellness program is great, I've been using it daily."</span><span class="p">,</span><span class="w">
  </span><span class="s2">"It's confusing and the app crashes constantly."</span><span class="p">,</span><span class="w">
  </span><span class="s2">"I haven't tried it yet but it sounds promising."</span><span class="w">
</span><span class="p">)</span><span class="w">

</span><span class="c1"># Verbose category descriptions classify several percentage points</span><span class="w">
</span><span class="c1"># more accurately than short labels, especially for smaller local</span><span class="w">
</span><span class="c1"># models like Qwen 14B below.</span><span class="w">
</span><span class="n">verbose_cats</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="nf">c</span><span class="p">(</span><span class="w">
  </span><span class="s2">"Positive: The respondent expresses satisfaction, approval, or favorable sentiment."</span><span class="p">,</span><span class="w">
  </span><span class="s2">"Negative: The respondent expresses dissatisfaction, frustration, or criticism."</span><span class="p">,</span><span class="w">
  </span><span class="s2">"Neutral: The respondent is factual, ambivalent, or does not express clear sentiment."</span><span class="p">,</span><span class="w">
  </span><span class="s2">"Other: The response does not fit any of the above categories."</span><span class="w">
</span><span class="p">)</span><span class="w">

</span><span class="n">results</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="n">classify</span><span class="p">(</span><span class="w">
  </span><span class="n">input_data</span><span class="w">   </span><span class="o">=</span><span class="w"> </span><span class="n">responses</span><span class="p">,</span><span class="w">
  </span><span class="n">categories</span><span class="w">   </span><span class="o">=</span><span class="w"> </span><span class="n">verbose_cats</span><span class="p">,</span><span class="w">
  </span><span class="n">user_model</span><span class="w">   </span><span class="o">=</span><span class="w"> </span><span class="s2">"qwen2.5:14b"</span><span class="p">,</span><span class="w">
  </span><span class="n">model_source</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"ollama"</span><span class="w">
</span><span class="p">)</span><span class="w">

</span><span class="n">print</span><span class="p">(</span><span class="n">results</span><span class="p">)</span><span class="w">
</span></code></pre></div></div>

<p>You get back a data frame with one column per category and one row per response, a binary matrix ready to merge back into your main dataset. The Ollama server starts automatically the first time you call <code class="language-plaintext highlighter-rouge">classify()</code> with <code class="language-plaintext highlighter-rouge">model_source = "ollama"</code>; you do not have to manage <code class="language-plaintext highlighter-rouge">ollama serve</code> yourself.</p>

<p>To run the same example against a cloud model, swap <code class="language-plaintext highlighter-rouge">model_source = "ollama"</code> and <code class="language-plaintext highlighter-rouge">user_model = "qwen2.5:14b"</code> for <code class="language-plaintext highlighter-rouge">user_model = "gpt-5"</code> (or <code class="language-plaintext highlighter-rouge">"claude-sonnet-4-6"</code>, <code class="language-plaintext highlighter-rouge">"gemini-2.0-flash"</code>, etc.) and pass <code class="language-plaintext highlighter-rouge">api_key = Sys.getenv("OPENAI_API_KEY")</code>. Everything else stays the same.</p>

<h2 id="the-eight-r-packages">The Eight R Packages</h2>

<p>Each domain package depends on <code class="language-plaintext highlighter-rouge">cat.stack</code> (the shared classification engine) and adds parameters relevant to its data type.</p>

<table>
  <thead>
    <tr>
      <th>Package</th>
      <th>Domain</th>
      <th>What it adds</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>cat.stack</strong></td>
      <td>General-purpose classification base</td>
      <td>Core <code class="language-plaintext highlighter-rouge">classify()</code>, <code class="language-plaintext highlighter-rouge">extract()</code>, <code class="language-plaintext highlighter-rouge">explore()</code>, <code class="language-plaintext highlighter-rouge">summarize()</code></td>
    </tr>
    <tr>
      <td><strong>cat.survey</strong></td>
      <td>Open-ended survey responses</td>
      <td><code class="language-plaintext highlighter-rouge">survey_question</code> parameter for question context</td>
    </tr>
    <tr>
      <td><strong>cat.vader</strong></td>
      <td>Social media posts</td>
      <td>Threads/Bluesky metadata, sentiment-tuned defaults</td>
    </tr>
    <tr>
      <td><strong>cat.ademic</strong></td>
      <td>Academic papers (OpenAlex)</td>
      <td><code class="language-plaintext highlighter-rouge">journal_name</code>, <code class="language-plaintext highlighter-rouge">topic_name</code>, <code class="language-plaintext highlighter-rouge">paper_limit</code> fetchers</td>
    </tr>
    <tr>
      <td><strong>cat.cog</strong></td>
      <td>Cognitive assessment scoring</td>
      <td><code class="language-plaintext highlighter-rouge">cerad_drawn_score()</code> for CERAD constructional praxis</td>
    </tr>
    <tr>
      <td><strong>cat.pol</strong></td>
      <td>Policy documents</td>
      <td>Fetchers for city ordinances, federal laws, exec orders</td>
    </tr>
    <tr>
      <td><strong>cat.web</strong></td>
      <td>Web content (URL fetching)</td>
      <td>Auto-fetch URLs, inject domain + content-type metadata</td>
    </tr>
    <tr>
      <td><strong>cat.llm</strong></td>
      <td>Meta-package (installs all 7)</td>
      <td>Single install for the full ecosystem</td>
    </tr>
  </tbody>
</table>

<p>A few examples of what the domain-specific parameters look like.</p>

<p><strong><code class="language-plaintext highlighter-rouge">cat.ademic</code></strong>: pull abstracts directly from OpenAlex and classify them by method:</p>

<div class="language-r highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">library</span><span class="p">(</span><span class="n">cat.ademic</span><span class="p">)</span><span class="w">

</span><span class="n">results</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="n">classify</span><span class="p">(</span><span class="w">
  </span><span class="n">categories</span><span class="w">   </span><span class="o">=</span><span class="w"> </span><span class="nf">c</span><span class="p">(</span><span class="s2">"Quantitative"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Qualitative"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Mixed Methods"</span><span class="p">),</span><span class="w">
  </span><span class="n">journal_name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"American Sociological Review"</span><span class="p">,</span><span class="w">
  </span><span class="n">paper_limit</span><span class="w">  </span><span class="o">=</span><span class="w"> </span><span class="m">100L</span><span class="p">,</span><span class="w">
  </span><span class="n">polite_email</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"you@university.edu"</span><span class="p">,</span><span class="w">
  </span><span class="n">api_key</span><span class="w">      </span><span class="o">=</span><span class="w"> </span><span class="n">Sys.getenv</span><span class="p">(</span><span class="s2">"OPENAI_API_KEY"</span><span class="p">)</span><span class="w">
</span><span class="p">)</span><span class="w">
</span></code></pre></div></div>

<p>No data frame to assemble; <code class="language-plaintext highlighter-rouge">journal_name</code> and <code class="language-plaintext highlighter-rouge">paper_limit</code> do the fetching for you.</p>

<p><strong><code class="language-plaintext highlighter-rouge">cat.web</code></strong>: point at URLs and let the package render the page content before classification:</p>

<div class="language-r highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">library</span><span class="p">(</span><span class="n">cat.web</span><span class="p">)</span><span class="w">

</span><span class="n">results</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="n">classify</span><span class="p">(</span><span class="w">
  </span><span class="n">input_data</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nf">c</span><span class="p">(</span><span class="s2">"https://example.com/post-1"</span><span class="p">,</span><span class="w"> </span><span class="s2">"https://example.com/post-2"</span><span class="p">),</span><span class="w">
  </span><span class="n">categories</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nf">c</span><span class="p">(</span><span class="s2">"News"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Opinion"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Personal blog"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Documentation"</span><span class="p">),</span><span class="w">
  </span><span class="n">api_key</span><span class="w">    </span><span class="o">=</span><span class="w"> </span><span class="n">Sys.getenv</span><span class="p">(</span><span class="s2">"OPENAI_API_KEY"</span><span class="p">)</span><span class="w">
</span><span class="p">)</span><span class="w">
</span></code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">cat.cog</code></strong>: a single-purpose function for scoring CERAD constructional-praxis drawings (image input only, for clinical and cognitive-aging research):</p>

<div class="language-r highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">library</span><span class="p">(</span><span class="n">cat.cog</span><span class="p">)</span><span class="w">

</span><span class="n">score</span><span class="w"> </span><span class="o">&lt;-</span><span class="w"> </span><span class="n">cerad_drawn_score</span><span class="p">(</span><span class="w">
  </span><span class="n">image_path</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"path/to/participant-drawing.png"</span><span class="p">,</span><span class="w">
  </span><span class="n">api_key</span><span class="w">    </span><span class="o">=</span><span class="w"> </span><span class="n">Sys.getenv</span><span class="p">(</span><span class="s2">"OPENAI_API_KEY"</span><span class="p">)</span><span class="w">
</span><span class="p">)</span><span class="w">
</span></code></pre></div></div>

<p>The remaining packages (<code class="language-plaintext highlighter-rouge">cat.survey</code>, <code class="language-plaintext highlighter-rouge">cat.vader</code>, <code class="language-plaintext highlighter-rouge">cat.pol</code>, <code class="language-plaintext highlighter-rouge">cat.stack</code>) all share the same <code class="language-plaintext highlighter-rouge">classify()</code> / <code class="language-plaintext highlighter-rouge">extract()</code> / <code class="language-plaintext highlighter-rouge">explore()</code> / <code class="language-plaintext highlighter-rouge">summarize()</code> API; the vignettes show domain-tuned examples for each.</p>

<h2 id="verifying-the-r-package-install">Verifying the R Package Install</h2>

<p>After cloning the parent repo, this one command installs all eight R packages from local source, sets up the Python backends, and runs a minimal classification per package:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">OPENAI_API_KEY</span><span class="o">=</span>sk-... Rscript r-package/test-all-packages.R
</code></pre></div></div>

<p>Expected output: <code class="language-plaintext highlighter-rouge">8 / 8 passed (0 failed, 0 skipped)</code>. CI runs the same script on every push.</p>

<h2 id="caveats">Caveats</h2>

<ul>
  <li><strong>Not on CRAN yet.</strong> R-universe only while the API stabilizes.</li>
  <li><strong>Python ≥3.9 required.</strong> <code class="language-plaintext highlighter-rouge">install_cat_stack()</code> runs <code class="language-plaintext highlighter-rouge">pip install</code> under the hood; if you do not have Python, reticulate will prompt you to install miniconda.</li>
  <li><strong>For Ollama workflows</strong>, the Ollama binary is a separate download from <a href="https://ollama.com/download">ollama.com</a>. Local 7B–14B models trail frontier cloud models by a few percentage points on classification accuracy, which is fine for most survey work but worth validating against a labeled subsample for high-stakes coding.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">cat.cog</code> is single-function for now.</strong> CERAD scoring on drawn-shape images. More cognitive instruments to come.</li>
</ul>

<h2 id="links">Links</h2>

<ul>
  <li><strong>R packages docs:</strong> <a href="https://christophersoria.com/cat-llm/">christophersoria.com/cat-llm/</a></li>
  <li><strong>Getting Started vignette:</strong> <a href="https://christophersoria.com/cat-llm/cat.llm/articles/getting-started.html">cat.llm/articles/getting-started</a></li>
  <li><strong>Python Package:</strong> <a href="https://pypi.org/project/cat-llm/">pypi.org/project/cat-llm/</a></li>
  <li><strong>GitHub:</strong> <a href="https://github.com/chrissoria/cat-llm">github.com/chrissoria/cat-llm</a></li>
</ul>

<p>If you run into trouble installing, hit a confusing error from reticulate, or want to suggest a domain wrapper that does not exist yet, reach out at <a href="mailto:ChrisSoria@Berkeley.edu">ChrisSoria@Berkeley.edu</a>.</p>]]></content><author><name>Contact Information</name><email>chrissoria@berkeley.edu</email></author><category term="CatLLM" /><category term="R" /><category term="R Package" /><category term="Reticulate" /><category term="Large Language Models" /><category term="Text Classification" /><category term="Survey Data" /><category term="NLP" /><category term="Open Source" /><summary type="html"><![CDATA[CatLLM is now available as eight R packages on r-universe. Classify open-ended survey responses, social media, web content, academic papers, and policy documents with any LLM, directly from R via reticulate.]]></summary></entry><entry><title type="html">What California Cities Actually Legislate: Classifying Municipal Ordinances with cat-pol</title><link href="https://christophersoria.com/posts/2026/03/catpol-ordinance-analysis/" rel="alternate" type="text/html" title="What California Cities Actually Legislate: Classifying Municipal Ordinances with cat-pol" /><published>2026-03-22T00:00:00-07:00</published><updated>2026-03-22T00:00:00-07:00</updated><id>https://christophersoria.com/posts/2026/03/catpol-ordinance-analysis</id><content type="html" xml:base="https://christophersoria.com/posts/2026/03/catpol-ordinance-analysis/"><![CDATA[<p><img src="/images/catpol-ordinance-banner.png" alt="" /></p>

<!-- TODO: generate audio version -->

<p>Local laws shape daily life in ways that most people never see — your rent, your commute, what gets built on the corner lot — but they’re written in dense legal language, buried in city clerk archives, and produced at a volume no individual can keep up with. San Diego alone has passed nearly 90,000 ordinances and resolutions. San Francisco adds dozens per month. Journalists cover the headline votes; researchers study federal legislation; but the vast majority of municipal lawmaking happens without any systematic analysis at all.</p>

<p><strong><a href="https://github.com/chrissoria/cat-pol">cat-pol</a></strong> is designed to close that gap. It’s an open-source Python package that pulls municipal ordinances, federal laws, executive orders, and political speech directly from public datasets, then uses LLMs to classify, summarize, and analyze them at scale. Built on top of <a href="https://github.com/chrissoria/cat-stack">cat-stack</a>, it can take a 15,000-word ordinance written in statutory language and tell you, in plain English, what it does, who it affects, and where it falls on the political spectrum.</p>

<p>In this post, I used <a href="https://github.com/chrissoria/cat-pol">cat-pol</a> to classify 200 recent ordinances each from <strong>San Diego</strong> and <strong>San Francisco</strong> — two major California cities with different political characters, against two classification schemes: a 12-category policy taxonomy and a 3-category political lean assessment. The goal: a quantitative snapshot of what these cities legislate about and whether the ideological differences between them show up in the text of their laws.</p>

<p><em>Want to run this on your own data? Skip to the <a href="#how-to-run-it-yourself">methodology and replication section</a>. The entire pipeline is open source and the datasets are public.</em></p>

<hr />

<h2 id="background-cat-pol-and-the-data">Background: cat-pol and the Data</h2>

<p><strong><a href="https://github.com/chrissoria/cat-stack">cat-stack</a></strong> is a domain-agnostic LLM classification engine. <strong><a href="https://pypi.org/project/cat-pol/">cat-pol</a></strong> (<code class="language-plaintext highlighter-rouge">pip install cat-pol</code>) is its political text wrapper, shipping with 17 built-in political data sources on HuggingFace, including municipal ordinances from 12 California cities and counties, federal public laws, executive orders, presidential speeches, and Trump’s Truth Social posts, all accessible with a single <code class="language-plaintext highlighter-rouge">source=</code> parameter.</p>

<p>For this analysis, the data comes from two HuggingFace datasets:</p>
<ul>
  <li><strong><a href="https://huggingface.co/datasets/chrissoria/san-diego-ordinances">chrissoria/san-diego-ordinances</a></strong> — 87,983 records (ordinances + resolutions) going back to 1905</li>
  <li><strong><a href="https://huggingface.co/datasets/chrissoria/sf-ordinances">chrissoria/sf-ordinances</a></strong> — 4,048 ordinances going back to 2011</li>
</ul>

<p>Both datasets are scraped from official city clerk systems, include full ordinance text extracted from PDFs, and are updated weekly via automated scrapers. I took the 200 most recent ordinances with text from each city.</p>

<p>What follows is a demonstration of what you can learn from municipal legislation using a few lines of Python and no specialized legal knowledge. The entire analysis can be reproduced for free: the data is publicly hosted on <a href="https://huggingface.co/chrissoria">HuggingFace</a>, cat-pol is <a href="https://github.com/chrissoria/cat-pol">open source</a>, and the classification can run on free-tier HuggingFace models or local models via <a href="https://ollama.com">Ollama</a> with zero API costs. If you can write <code class="language-plaintext highlighter-rouge">pip install cat-pol</code>, you can replicate and extend every finding below.</p>

<hr />

<h2 id="san-francisco-passes-twice-as-many-laws">San Francisco Passes Twice as Many Laws</h2>

<p>Before asking <em>what</em> these cities legislate about, it’s worth asking <em>how much</em> they legislate.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>San Diego</th>
      <th>San Francisco</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ordinances per month (2020–2025 avg)</td>
      <td>11.2</td>
      <td><strong>22.2</strong></td>
    </tr>
    <tr>
      <td>Ratio</td>
      <td> </td>
      <td><strong>2.0x</strong></td>
    </tr>
  </tbody>
</table>

<p>San Francisco passes roughly <strong>twice as many ordinances per month</strong> as San Diego: 22 vs 11. The pattern is stable across years, not a spike. SF consistently produces 20–25 ordinances per month; SD produces 9–15.</p>

<p>San Diego compensates with a high volume of <em>resolutions</em> (about 49 per month), which are administrative actions (contract approvals, budget items, proclamations) rather than new law. But in terms of actual lawmaking (text that creates, amends, or repeals municipal code), SF is substantially more productive.</p>

<p>This matters for everything that follows. When we compare the <em>share</em> of ordinances in each policy domain, we’re comparing slices of very different pies. A 24% business regulation rate in SF means roughly 5 new business ordinances per month. An 8% rate in SD means less than 1. Throughout the rest of this analysis, I’ll show both percentages and estimated monthly counts to keep the denominators honest.</p>

<hr />

<h2 id="what-do-these-cities-legislate-about">What Do These Cities Legislate About?</h2>

<p>Rather than imposing categories top-down, I used cat-pol’s <code class="language-plaintext highlighter-rouge">extract()</code> function to discover 12 policy domains directly from the ordinance text. Categories are multi-label, so a single ordinance can be tagged with multiple domains. See the <a href="#methodology-notes">methodology section</a> for how the categories were generated and the full list.</p>

<h3 id="results-policy-domain-distribution">Results: Policy Domain Distribution</h3>

<p><img src="/images/catpol-policy-distribution.png" alt="" /></p>

<p><img src="/images/catpol-policy-gap.png" alt="" /></p>

<p><em>200 most recent ordinances per city. Multi-label (rows sum to &gt;100%). Model: Qwen 2.5-72B-Instruct.</em></p>

<h3 id="what-the-numbers-mean">What the Numbers Mean</h3>

<p>The two cities legislate about fundamentally different things.</p>

<p><strong>San Diego is a building city.</strong> Nearly half (43%) of its recent ordinances touch infrastructure and public works: road improvements, water main replacements, sewer projects, construction contract extensions. Even though SD passes half as many ordinances overall, it still produces <strong>more infrastructure legislation per month in absolute terms</strong> than SF (4.8 vs 3.3). This isn’t just proportional: SD is genuinely more active on physical infrastructure than SF is. The same holds for parks (1.8 vs 2.3/month, close despite SD’s half-sized legislative output). SD’s legislative agenda reads like a city physically constructing and maintaining itself.</p>

<p><strong>San Francisco is a regulating city.</strong> Its ordinances spread across a wider range of policy domains, with no single category exceeding 27%. The biggest concentrations are in zoning and land use (6.0/month), revenue and financing (5.6/month), and business regulation (5.4/month). Health and social services, a category that barely registers in San Diego at 0.7 ordinances per month, hits <strong>5.3 per month</strong> in SF. That’s not a rounding difference; it’s a 7x gap. Housing policy runs at roughly triple SD’s rate (3.7 vs 1.3/month). San Francisco’s legislative output reads like a city managing social complexity: who can build what, under what conditions, with what protections for whom.</p>

<p>The environmental protection rate is nearly identical between the two cities (~17%), suggesting this is a baseline concern for California municipalities regardless of political character. Tax increases are rare in both (under 3%), which likely reflects the political difficulty of explicit tax votes at the local level.</p>

<blockquote>
  <p><strong>SF legislates more about business in <em>both</em> directions.</strong> San Francisco scores higher than San Diego on business regulation (24% vs 8%) <em>and</em> pro-business/economic development (17% vs 5%). It creates more rules and more incentives. San Diego’s approach to business is to leave it alone. The legislative silence is itself a policy choice.</p>
</blockquote>

<blockquote>
  <p><strong>SF actively raises revenue to fund social programs.</strong> SF generates 25% of its ordinances around revenue and financing, compared to SD’s 16%. Combined with SF’s 24% rate on health and social services (vs SD’s 6%), the picture is a city that raises money to fund an interventionist social agenda. San Diego raises less and spends what it raises on concrete: roads, pipes, parks.</p>
</blockquote>

<p>Put differently: <strong>San Diego legislates like a city that wants to run well. San Francisco legislates like a city that wants to do good.</strong> Whether “doing good” through regulation and social programs actually produces better outcomes is a separate empirical question, but the legislative priorities are unmistakable in the data. SD’s council spends its time keeping the lights on and the water flowing. SF’s council spends its time deciding who gets housing protections, which businesses need new permits, and how to fund homelessness services.</p>

<p>As a robustness check, I re-ran the analysis on 1,000 ordinances per city using GPT-4o with a different, data-driven category scheme. Different model, five times the sample, looser categories. Same conclusion: SD dominates on infrastructure and construction; SF dominates on health, environment, and housing. The pattern holds.</p>

<hr />

<h2 id="part-2-do-ordinances-have-a-political-lean">Part 2: Do Ordinances Have a Political Lean?</h2>

<h3 id="category-setup">Category Setup</h3>

<p>For the political lean analysis, I used three categories designed to capture ideological orientation:</p>

<ol>
  <li>
    <p><strong>Conservative/Right-Leaning Policy</strong> — deregulation, tax cuts, pro-business measures, law enforcement expansion, property rights protections, reduced government intervention, privatization of services</p>
  </li>
  <li>
    <p><strong>Progressive/Left-Leaning Policy</strong> — new regulations, tax increases, tenant protections, environmental mandates, social services expansion, equity/inclusion initiatives, labor protections, police reform</p>
  </li>
  <li>
    <p><strong>Neutral</strong> — routine contract approvals, procedural amendments, election scheduling, civil service appointments, budget housekeeping with no policy direction</p>
  </li>
</ol>

<p>This classification used <strong>Qwen3-235B</strong>, the flagship thinking model, to see whether reasoning capability helps with the more nuanced task of ideological classification.</p>

<h3 id="results-political-lean-distribution">Results: Political Lean Distribution</h3>

<p><img src="/images/catpol-political-lean.png" alt="" /></p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>San Diego</th>
      <th>San Francisco</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Conservative/Right-Leaning</td>
      <td>2.6%</td>
      <td><strong>23.1%</strong></td>
    </tr>
    <tr>
      <td>Progressive/Left-Leaning</td>
      <td>19.9%</td>
      <td><strong>31.8%</strong></td>
    </tr>
    <tr>
      <td>Neutral</td>
      <td><strong>77.6%</strong></td>
      <td>45.6%</td>
    </tr>
  </tbody>
</table>

<p><em>200 most recent ordinances per city. Multi-label classification. Model: Qwen3-235B (thinking model).</em></p>

<p>The results are counterintuitive, and that’s what makes them interesting.</p>

<p><strong>San Diego’s ordinances are overwhelmingly neutral.</strong> Nearly 78% of SD’s recent ordinances carry no detectable ideological lean. These are contract extensions, budget transfers, construction authorizations. The machinery of a city that governs by administration rather than ideology. Only 3% code as conservative and 20% as progressive.</p>

<p><strong>San Francisco legislates politically in both directions.</strong> SF has a higher progressive rate (32% vs 20%), which aligns with expectations. But the surprise is SF’s conservative rate: <strong>23% vs SD’s 3%</strong>. San Francisco produces nearly 9x more conservative-coded ordinances than San Diego.</p>

<p>This isn’t because San Francisco has a secret conservative agenda. It’s because SF <em>actively legislates</em> about the domains that register as ideological: business regulation, tax policy, development incentives, policing. When you pass an ordinance streamlining permits for small businesses, that codes as pro-business/conservative. When you pass an ordinance adding tenant protections, that codes as progressive. San Diego doesn’t pass either ordinance. It passes a contract amendment instead.</p>

<p>The neutral rate tells the real story. SD’s 78% neutral rate means its city council spends most of its time on administrative governance. SF’s 46% neutral rate means less than half of its legislative output is purely procedural. The majority of SF ordinances take a policy stance of some kind. <strong>SF doesn’t just lean left — it legislates ideologically, period.</strong> It takes more stances in more directions than San Diego takes in any direction.</p>

<hr />

<h2 id="methodology-notes">Methodology Notes</h2>

<p>A few important details about how this analysis works and where it might break.</p>

<p><strong>Multi-label classification.</strong> Each ordinance can be tagged with multiple categories simultaneously. An infrastructure bond gets both “Infrastructure” and “Revenue and Financing.” This means percentages sum to more than 100%. That’s by design, not a bug.</p>

<p><strong>Model choice.</strong> I used two models: <strong>Qwen 2.5-72B-Instruct</strong> for the 12-category policy domain classification and <strong>Qwen3-235B</strong> (a 235-billion parameter mixture-of-experts thinking model) for the political lean analysis. Both are open-source models accessed via HuggingFace’s inference API, no OpenAI dependency. The robustness check used GPT-4o on a larger sample to confirm the results hold across model families.</p>

<p><strong>Category discovery.</strong> Categories weren’t hand-picked. I used <code class="language-plaintext highlighter-rouge">pol.extract()</code> to sample 50 ordinances and let the LLM discover recurring themes, then semantically merged duplicates into a clean taxonomy. The <code class="language-plaintext highlighter-rouge">specificity="specific"</code> parameter ensures category names include examples, which significantly improves classification accuracy over bare labels. The 12 policy domain categories used:</p>

<ol>
  <li>Tax Increases 2. Revenue and Financing 3. Budget and Appropriations 4. Housing and Residential Development 5. Zoning and Land Use Changes 6. Infrastructure and Public Works 7. Business Regulation 8. Pro-Business and Economic Development 9. Environmental Protection 10. Public Safety 11. Health and Social Services 12. Parks, Recreation, and Culture</li>
</ol>

<p>For the political lean analysis, three categories: Conservative/Right-Leaning Policy, Progressive/Left-Leaning Policy, and Neutral.</p>

<p><strong>Limitations.</strong> There’s no ground truth here — no human-coded comparison set for municipal ordinances. The classifications reflect what the model <em>thinks</em> these ordinances are about, not an objective standard. Model bias is a real concern, especially for the political lean analysis; LLMs have known tendencies in how they interpret political language. The sample sizes (200 for the Qwen runs, 1,000 for the GPT-4o robustness check) are reasonable but not exhaustive. The full classified datasets (1,700 SD ordinances, 3,900 SF ordinances) are now public on HuggingFace for anyone who wants to validate or extend this analysis.</p>

<hr />

<h2 id="the-public-datasets">The Public Datasets</h2>

<p>All data and classification results are publicly available:</p>

<ul>
  <li><strong>San Diego ordinances</strong>: <a href="https://huggingface.co/datasets/chrissoria/san-diego-ordinances">chrissoria/san-diego-ordinances</a> (87,983 records)</li>
  <li><strong>San Francisco ordinances</strong>: <a href="https://huggingface.co/datasets/chrissoria/sf-ordinances">chrissoria/sf-ordinances</a> (4,048 records)
The source registry includes 17 datasets across California cities and counties, federal legislation, and social media:</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">cat_pol</span> <span class="k">as</span> <span class="n">pol</span>

<span class="n">pol</span><span class="p">.</span><span class="n">list_sources</span><span class="p">()</span>  <span class="c1"># see all 17 sources
</span></code></pre></div></div>

<hr />

<h2 id="how-to-run-it-yourself">How to Run It Yourself</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>cat-pol
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">cat_pol</span> <span class="k">as</span> <span class="n">pol</span>

<span class="c1"># Discover categories from your data
</span><span class="n">categories</span> <span class="o">=</span> <span class="n">pol</span><span class="p">.</span><span class="n">extract</span><span class="p">(</span>
    <span class="n">source</span><span class="o">=</span><span class="s">"city_san_diego"</span><span class="p">,</span>
    <span class="n">doc_type</span><span class="o">=</span><span class="s">"ordinance"</span><span class="p">,</span>
    <span class="n">n</span><span class="o">=</span><span class="mi">50</span><span class="p">,</span>
    <span class="n">api_key</span><span class="o">=</span><span class="s">"your-key"</span><span class="p">,</span>
    <span class="n">user_model</span><span class="o">=</span><span class="s">"Qwen/Qwen2.5-72B-Instruct"</span><span class="p">,</span>
    <span class="n">model_source</span><span class="o">=</span><span class="s">"huggingface"</span><span class="p">,</span>
    <span class="n">max_categories</span><span class="o">=</span><span class="mi">12</span><span class="p">,</span>
    <span class="n">specificity</span><span class="o">=</span><span class="s">"specific"</span><span class="p">,</span>
<span class="p">)</span>

<span class="c1"># Classify using discovered categories
</span><span class="n">results</span> <span class="o">=</span> <span class="n">pol</span><span class="p">.</span><span class="n">classify</span><span class="p">(</span>
    <span class="n">source</span><span class="o">=</span><span class="s">"city_san_diego"</span><span class="p">,</span>
    <span class="n">categories</span><span class="o">=</span><span class="n">categories</span><span class="p">[</span><span class="s">"top_categories"</span><span class="p">],</span>
    <span class="n">doc_type</span><span class="o">=</span><span class="s">"ordinance"</span><span class="p">,</span>
    <span class="n">n</span><span class="o">=</span><span class="mi">200</span><span class="p">,</span>
    <span class="n">api_key</span><span class="o">=</span><span class="s">"your-key"</span><span class="p">,</span>
    <span class="n">user_model</span><span class="o">=</span><span class="s">"Qwen/Qwen2.5-72B-Instruct"</span><span class="p">,</span>
    <span class="n">model_source</span><span class="o">=</span><span class="s">"huggingface"</span><span class="p">,</span>
<span class="p">)</span>

<span class="c1"># Summarize in plain language
</span><span class="n">summaries</span> <span class="o">=</span> <span class="n">pol</span><span class="p">.</span><span class="n">summarize</span><span class="p">(</span>
    <span class="n">source</span><span class="o">=</span><span class="s">"city_san_diego"</span><span class="p">,</span>
    <span class="n">n</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span>
    <span class="nb">format</span><span class="o">=</span><span class="s">"bullets"</span><span class="p">,</span>
    <span class="n">tone</span><span class="o">=</span><span class="s">"eli5"</span><span class="p">,</span>
    <span class="n">api_key</span><span class="o">=</span><span class="s">"your-key"</span><span class="p">,</span>
<span class="p">)</span>

<span class="c1"># Optimize prompts with user feedback
</span><span class="n">result</span> <span class="o">=</span> <span class="n">pol</span><span class="p">.</span><span class="n">prompt_tune</span><span class="p">(</span>
    <span class="n">source</span><span class="o">=</span><span class="s">"city_san_diego"</span><span class="p">,</span>
    <span class="n">categories</span><span class="o">=</span><span class="p">[</span><span class="s">"Housing"</span><span class="p">,</span> <span class="s">"Public Safety"</span><span class="p">,</span> <span class="s">"Finance"</span><span class="p">],</span>
    <span class="n">api_key</span><span class="o">=</span><span class="s">"your-key"</span><span class="p">,</span>
    <span class="n">sample_size</span><span class="o">=</span><span class="mi">15</span><span class="p">,</span>
<span class="p">)</span>

<span class="c1"># Fetch raw data (no API key needed)
</span><span class="n">df</span> <span class="o">=</span> <span class="n">pol</span><span class="p">.</span><span class="n">fetch_source</span><span class="p">(</span><span class="s">"city_san_diego"</span><span class="p">,</span> <span class="n">n</span><span class="o">=</span><span class="mi">100</span><span class="p">,</span> <span class="n">doc_type</span><span class="o">=</span><span class="s">"ordinance"</span><span class="p">)</span>
</code></pre></div></div>

<p>cat-pol handles the data pull, classification, and output in a consistent pipeline. Every dataset in the registry uses the same <code class="language-plaintext highlighter-rouge">source=</code> parameter across all five functions (<code class="language-plaintext highlighter-rouge">classify</code>, <code class="language-plaintext highlighter-rouge">extract</code>, <code class="language-plaintext highlighter-rouge">explore</code>, <code class="language-plaintext highlighter-rouge">summarize</code>, <code class="language-plaintext highlighter-rouge">prompt_tune</code>) plus <code class="language-plaintext highlighter-rouge">fetch_source</code> and <code class="language-plaintext highlighter-rouge">list_sources</code>.</p>

<hr />

<hr />

<h2 id="what-this-tells-us">What This Tells Us</h2>

<p>The consistent finding across every analysis (different models, different sample sizes, different category schemes) is that San Diego and San Francisco govern in fundamentally different modes.</p>

<p>San Diego governs through <strong>administration</strong>: infrastructure projects, contract management, budget operations. Its ordinances are largely neutral, procedural, and focused on keeping the physical city running. When SD does legislate on policy, it tilts modestly progressive, but most of its legislative energy goes to the apolitical work of urban maintenance.</p>

<p>San Francisco governs through <strong>policy</strong>: zoning, business regulation, health services, housing, environmental mandates. Its ordinances are more likely to carry an ideological valence, progressive <em>and</em> conservative — because SF actively codifies political values into law. It produces twice as many ordinances per month, and more than half of them take a policy stance.</p>

<p>Neither mode is inherently better. SD’s approach keeps government lean and focused; SF’s approach uses legislation as a tool for social intervention. But the data makes clear that these aren’t just different political leanings. They’re different <em>theories of what city government is for</em>.</p>

<h3 id="whats-next">What’s Next</h3>

<p>This analysis covers two cities. cat-pol ships with 17 data sources (and growing): 12 California cities, San Diego County, federal public laws, executive orders, presidential speeches, and Trump’s Truth Social posts. The same classification pipeline can be applied to any of them. Some questions I haven’t answered:</p>

<ul>
  <li><strong>Time series</strong>: Has SF always been this regulatory, or did it shift after a particular election?</li>
  <li><strong>City-size effects</strong>: Do smaller cities (Salinas, Clovis) look more like SD or SF?</li>
  <li><strong>County vs city</strong>: SD County just went live as a dataset — does county governance look different from city governance in the same jurisdiction?</li>
  <li><strong>Federal comparison</strong>: How do municipal policy domains map onto federal legislation?</li>
</ul>

<p>If you build something with cat-pol or the datasets, reach out at <a href="mailto:chrissoria@berkeley.edu">chrissoria@berkeley.edu</a>.</p>]]></content><author><name>Contact Information</name><email>chrissoria@berkeley.edu</email></author><category term="LLM" /><category term="political science" /><category term="municipal policy" /><category term="cat-pol" /><category term="NLP" /><category term="open source" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Upcoming Presentations and Conferences (Summer–Fall 2026)</title><link href="https://christophersoria.com/posts/2026/03/upcoming-presentations-2026/" rel="alternate" type="text/html" title="Upcoming Presentations and Conferences (Summer–Fall 2026)" /><published>2026-03-13T00:00:00-07:00</published><updated>2026-03-13T00:00:00-07:00</updated><id>https://christophersoria.com/posts/2026/03/upcoming-presentations-2026</id><content type="html" xml:base="https://christophersoria.com/posts/2026/03/upcoming-presentations-2026/"><![CDATA[<p>I have a busy conference season ahead. Here is where I’ll be presenting and what I’ll be talking about.</p>

<p><strong>PAA 2026</strong> — St. Louis, May 9</p>

<p><strong>AAPOR 2026</strong> — Los Angeles, May 13. Oral presentation on the <a href="/catllm/">CatLLM</a> pipeline for LLM-based survey response classification.</p>

<p><strong>EdDem Workshop</strong> — UW-Madison, May 14–15</p>

<p><strong>Sunbelt 2026</strong> — Daytona Beach, June 22–28. Oral presentation on social isolation, loneliness, and cognitive decline.</p>

<p><strong>AAIC 2026</strong> — London, July 12. Gateway/HCAP consensus session on the <a href="/publication/2025-08-29-cadas-protocol">CADAS</a> study, examining cross-national differences in web-based dementia diagnosis across clinician teams from five countries.</p>

<p><strong>ECMTB 2026</strong> — Graz, Austria, July 13–17. Minisymposium on advances in modeling human behavior and infectious disease spread.</p>

<p><strong>ASA 2026</strong> — New York, August 7–11 <em>(pending)</em></p>

<p><strong>ΨMCA 2026</strong> — August 9–14. Presentation on algorithmic classification in dementia research.</p>

<p><strong>Stata Conference 2026</strong> — Boston, October 1. Poster on the <a href="/catllm/">CatLLM</a> Stata package for LLM-assisted coding of open-ended survey text. Shared in the conference proceedings rather than presented in person.</p>

<p><strong>USC Gateway Brownbag</strong> — Fall semester</p>

<p><strong>GSA 2026</strong> — National Harbor, MD, November 4–7 <em>(pending)</em></p>

<p>The presentations span all three of my research streams: networks and cognitive aging (Sunbelt), computational methods for survey research (AAPOR), and applying algorithmic classification to dementia research (ΨMCA). If you’ll be at any of these, I’d love to connect.</p>]]></content><author><name>Contact Information</name><email>chrissoria@berkeley.edu</email></author><category term="Social Networks" /><category term="Cognitive Aging" /><category term="Large Language Models" /><category term="Dementia" /><category term="Conferences" /><summary type="html"><![CDATA[I have a busy conference season ahead. Here is where I’ll be presenting and what I’ll be talking about.]]></summary></entry><entry><title type="html">What Bluesky’s Most-Followed Accounts Actually Post About</title><link href="https://christophersoria.com/posts/2026/03/catvader-bluesky-analysis/" rel="alternate" type="text/html" title="What Bluesky’s Most-Followed Accounts Actually Post About" /><published>2026-03-05T00:00:00-08:00</published><updated>2026-03-05T00:00:00-08:00</updated><id>https://christophersoria.com/posts/2026/03/catvader-bluesky-analysis</id><content type="html" xml:base="https://christophersoria.com/posts/2026/03/catvader-bluesky-analysis/"><![CDATA[<p><img src="/images/catvader-bluesky-banner.png" alt="" /></p>

<audio controls="" style="width:100%">
  <source src="https://huggingface.co/datasets/chrissoria/blog-audio/resolve/main/catvader-bluesky-analysis.mp3" type="audio/mpeg" />
</audio>
<!-- Audio generated with edge_tts (en-US-BrianNeural) via convert_blog_to_audio.py in the repo root.
     To regenerate: cd chrissoria.github.io && python3 convert_blog_to_audio.py
     Hosted on HuggingFace: https://huggingface.co/datasets/chrissoria/blog-audio -->

<p>A few days ago I used <strong><a href="https://pypi.org/project/cat-vader/">cat-vader</a></strong> to analyze my own Threads feed — 582 posts, 9 categories, and a slightly uncomfortable amount of self-reflection. That analysis asked a personal question: what do <em>I</em> post about?</p>

<p>This one asks a broader question. I took the same classification pipeline and pointed it at ten of Bluesky’s most-followed accounts: AOC, Mark Cuban, Mark Hamill, The Onion, George Takei, The New York Times, Rachel Maddow, Stephen King, MeidasTouch, and NPR. These accounts span the ecosystem — a sitting congresswoman, a billionaire, a pop culture icon, a satirical news outlet, two major media organizations, an advocacy network, and a horror novelist. 250 posts per account, 2,500 posts total, all classified by GPT-4o-mini against a nine-category scheme.</p>

<p>What do Bluesky’s most-followed accounts post about? And more interestingly: once you control for <em>who</em> is posting, does content type actually predict engagement?</p>

<hr />

<h2 id="background-cat-vader-and-the-dataset">Background: cat-vader and the Dataset</h2>

<p><strong><a href="https://github.com/chrissoria/cat-vader">cat-vader</a></strong> is a fork of my open-source survey classification package <strong><a href="https://github.com/chrissoria/cat-llm">cat-llm</a></strong>, adapted for social media analysis. The core idea is simple: you give it a list of posts and a set of categories with descriptions, and it uses an LLM to check each post against each category independently. Because categories aren’t mutually exclusive, a single post can belong to multiple categories simultaneously — a Rachel Maddow post about deportation policy might be tagged as both Politics &amp; Elections and Social Issues &amp; Justice. That multi-label design is what makes the output useful for analysis rather than just binning.</p>

<p>For this analysis I pulled 250 posts from each of ten accounts using cat-vader’s <code class="language-plaintext highlighter-rouge">sm_source="bluesky"</code> integration, classified them all using GPT-4o-mini, and exported everything to a single CSV that’s now publicly available on Hugging Face at <strong><a href="https://huggingface.co/datasets/chrissoria/bluesky-top10-classified">chrissoria/bluesky-top10-classified</a></strong>.</p>

<p>One important note before diving in: <strong>Bluesky’s API returns <code class="language-plaintext highlighter-rouge">views = 0</code> for all posts.</strong> The platform either doesn’t track impression counts or doesn’t expose them. That means the engagement analysis here focuses entirely on <strong>likes and replies</strong> — two signals that are real and public, but not the same as reach.</p>

<hr />

<h2 id="category-setup">Category Setup</h2>

<p>Before running classification, I defined nine categories designed to capture the thematic range of these accounts. Rather than bare labels, each category gets a description that guides the model on borderline cases.</p>

<p><strong>1. Politics &amp; Elections</strong> — Posts about electoral dynamics, political parties, voting, candidates, or partisan maneuvering. Includes commentary on legislative proceedings, election results, and the behavior of political figures as political actors.</p>

<blockquote>
  <p><em>“I honestly believe our most powerful position in a toxic time that feeds on cynicism, apathy, &amp; despair is to genuinely care and act for a better world.”</em> — Alexandria Ocasio-Cortez (146,230 likes)</p>
</blockquote>

<p><strong>2. Trump &amp; MAGA Criticism</strong> — Posts directly targeting Donald Trump, his administration, his supporters, or the MAGA movement. Includes both policy critiques and character commentary.</p>

<blockquote>
  <p><em>“Trump Suffers Setback Unrelated To Child Rape”</em> — The Onion (10,746 likes)</p>
</blockquote>

<p><strong>3. Social Issues &amp; Justice</strong> — Posts about systemic inequality, civil rights, immigration enforcement, discrimination, or other social conditions. Focus is observational or normative rather than electoral.</p>

<blockquote>
  <p><em>“The owners of a Dallas County warehouse that ICE had planned to use as a mega detention center said Monday it will not sell or lease the property to the federal government. ‘God answered our prayers,’ the Hutchins Mayor said.”</em> — Rachel Maddow (21,132 likes)</p>
</blockquote>

<p><strong>4. News &amp; Current Events</strong> — Posts reporting on, linking to, or discussing recent news stories across any domain. Includes breaking news, investigative stories, and news aggregation.</p>

<blockquote>
  <p><em>“An NPR investigation finds the public database of Epstein files is missing dozens of pages related to sexual abuse accusations against President Trump.”</em> — NPR (10,185 likes)</p>
</blockquote>

<p><strong>5. Entertainment &amp; Pop Culture</strong> — Posts about film, television, music, celebrity, sports, books, or cultural moments. Includes personal fandom and cultural commentary.</p>

<blockquote>
  <p><em>“Bluesky is collegial and interesting, the way Twitter used to be. Bonus: most people can spell.”</em> — Stephen King (80,664 likes)</p>
</blockquote>

<p><strong>6. Humor &amp; Satire</strong> — Posts that are primarily comedic in intent: jokes, satirical takes, absurdist commentary, or ironic framings of current events.</p>

<blockquote>
  <p><em>“Netanyahu Calls Iran Strikes Necessary To Prevent War He Just Started”</em> — The Onion (23,571 likes)</p>
</blockquote>

<p><strong>7. Science &amp; Technology</strong> — Posts about scientific findings, technology developments, AI, climate science, medicine, or the intersection of tech and society.</p>

<p><strong>8. Economy &amp; Business</strong> — Posts about financial markets, economic conditions, corporate news, consumer prices, trade policy, or business developments.</p>

<blockquote>
  <p><em>“Mr. Cuban — I just wanted to quickly thank you. My husband has [cancer]. We went to pick up his medication and were informed it was $29,000. We were able to get it from CostPlus for $99.”</em> — Mark Cuban (12,276 likes)</p>
</blockquote>

<p><strong>9. Personal &amp; Lifestyle</strong> — Posts that are personal in nature: life updates, reflections, expressions of mood, personal milestones, or non-political opinion.</p>

<blockquote>
  <p><em>“We were married on this day in 1978… soulmates ever since.”</em> — Mark Hamill (53,889 likes)</p>
</blockquote>

<hr />

<h2 id="what-blueskys-top-accounts-post-about">What Bluesky’s Top Accounts Post About</h2>

<p><img src="/images/bluesky-category-distribution.png" alt="" /></p>

<p>The overall landscape has a clear hierarchy. <strong>News &amp; Current Events</strong> dominates at 58.7% of all 2,500 posts — nearly three in five posts link to or discuss a recent story. <strong>Politics &amp; Elections</strong> comes in second at 50.0%, meaning half of all posts across these accounts touch on politics in some form. Then there’s a significant drop to <strong>Social Issues &amp; Justice</strong> (26.3%), <strong>Entertainment &amp; Pop Culture</strong> (23.0%), and <strong>Humor &amp; Satire</strong> (18.0%). <strong>Trump &amp; MAGA Criticism</strong> sits at 16.2%. At the bottom: <strong>Economy &amp; Business</strong> (13.2%), <strong>Personal &amp; Lifestyle</strong> (8.1%), and <strong>Science &amp; Technology</strong> (5.8%).</p>

<p>The concentration at the top isn’t surprising for this particular set of accounts — these aren’t lifestyle influencers or tech reviewers. But the degree of dominance by News and Politics is still striking. More than half of what this slice of Bluesky produces is essentially political journalism or political commentary.</p>

<p><img src="/images/bluesky-category-by-account.png" alt="" /></p>

<p>The account-level breakdown is where things get interesting.</p>

<p><strong>NPR</strong> is the most single-mindedly focused: 93% of its posts fall under News &amp; Current Events, and 60% under Politics &amp; Elections. Almost nothing else. The Humor &amp; Satire bar is essentially invisible (1%). NPR’s Bluesky presence is a straight news wire.</p>

<p><strong>The Onion</strong> is the mirror image in the best way. 81% of Onion posts are tagged Humor &amp; Satire — the highest satire rate of any account — but also 60% Entertainment &amp; Pop Culture. What’s slightly surprising is that only 21% of The Onion’s posts are tagged Politics &amp; Elections directly, and only 9% Trump &amp; MAGA Criticism. Satirical headlines about Trump do get tagged politics when the framing is explicitly electoral, but a lot of Onion content uses political <em>subjects</em> in the service of pure absurdism, which the model correctly separates out.</p>

<p><strong>AOC</strong> leads in Politics &amp; Elections (68% of her posts), Social Issues &amp; Justice (36%), and News &amp; Current Events (55%). Almost no Humor, very little Economy. Her Bluesky presence reads like exactly what it is: a member of Congress doing political communication full-time.</p>

<p><strong>Mark Cuban</strong> is the biggest outlier in the dataset. He’s the only account where Economy &amp; Business is the dominant theme at 58% — more than any other category. He’s also one of the few accounts where Trump &amp; MAGA Criticism is near zero (under 1%). Cuban posts about healthcare costs, business models, tariff economics, and policy mechanics. His account has almost nothing in common with the others topically.</p>

<p><strong>Stephen King</strong> is the most balanced. He mixes Entertainment &amp; Pop Culture (62%), Politics &amp; Elections (27%), Humor &amp; Satire (23%), and News &amp; Current Events (24%) roughly evenly. His account feels like a person who actually posts about many things, not a media organization running a content strategy.</p>

<p><strong>Mark Hamill</strong> tilts heavily toward Entertainment &amp; Pop Culture (based on his posting about Star Wars, his marriage, personal reflections) alongside Politics — a mix that reflects his public persona as both a cultural figure and a vocal political voice.</p>

<hr />

<h2 id="who-gets-the-most-engagement">Who Gets the Most Engagement?</h2>

<p><img src="/images/bluesky-likes-by-account.png" alt="" /></p>

<p>There is not a close race here. <strong>AOC averages 34,674 likes per post</strong> — more than twice the second-place Mark Hamill at 12,049. After that: Stephen King (5,039), MeidasTouch (4,226), Rachel Maddow (3,744), The Onion (1,598), George Takei (1,016), NPR (303), Mark Cuban (139), and NYT at 91.</p>

<p>The top 10 most-liked posts in the dataset are all AOC’s, with the most-liked reaching 167,305 likes on a post about a protest in Tucson: <em>“Original projected attendance was 3,000 people. 23,000 showed up.”</em></p>

<p>The NPR and NYT numbers are particularly striking — both major media organizations with massive follower counts, but averaging under 300 and 100 likes per post respectively. The media organizations are generating engagement on Bluesky at a fraction of the rate of individual personalities. Whether that reflects the algorithm, audience behavior, content style, or all three is hard to disentangle from this data alone.</p>

<p><img src="/images/bluesky-engagement-by-category.png" alt="" /></p>

<p>By category, the engagement hierarchy tells a clean story. <strong>Social Issues &amp; Justice</strong> leads at 8,819 average likes per post, followed by <strong>Politics &amp; Elections</strong> at 7,795 and <strong>Trump &amp; MAGA Criticism</strong> at 6,849. <strong>Economy &amp; Business</strong> posts average just 2,790 likes; <strong>Science &amp; Technology</strong> posts average only 706 — by far the lowest of any category.</p>

<p>But engagement averages are heavily influenced by <em>who</em> posts in each category. AOC posts almost entirely in Politics and Social Issues; Mark Cuban posts almost entirely in Economy. These raw category averages are partly measuring account popularity, not content appeal in isolation.</p>

<hr />

<h2 id="what-predicts-likes">What Predicts Likes?</h2>

<p>To separate content from creator, I ran two regression models with <code class="language-plaintext highlighter-rouge">log(likes + 1)</code> as the outcome — a log transformation that handles the extreme right skew in likes (the median is 919, the max is 167,305).</p>

<p>The first model includes only the eight category indicators, with <strong>Personal &amp; Lifestyle</strong> as the omitted reference category. The second adds account fixed effects, which isolates the content effect from the very large differences in per-account audience.</p>

<p><img src="/images/bluesky-regression-likes.png" alt="" /></p>

<p>Before controlling for account, seven of eight categories are statistically significant predictors of likes relative to Personal &amp; Lifestyle posts. The strongest positive predictors:</p>

<ul>
  <li><strong>Entertainment &amp; Pop Culture</strong>: +1.04 log-units (the largest positive coefficient)</li>
  <li><strong>Politics &amp; Elections</strong>: +1.01</li>
  <li><strong>Trump &amp; MAGA Criticism</strong>: +0.99</li>
  <li><strong>Humor &amp; Satire</strong>: +0.77</li>
  <li><strong>Social Issues &amp; Justice</strong>: +0.60</li>
  <li><strong>News &amp; Current Events</strong>: +0.29</li>
</ul>

<p>The two negative predictors are striking: <strong>Science &amp; Technology</strong> (−0.47) and especially <strong>Economy &amp; Business</strong> (−1.96) — the single largest coefficient in the model, and hugely negative. An economics post generates dramatically fewer likes than a comparable personal post in this raw model. The model explains 18.1% of the variance in log-likes.</p>

<p>But much of this is just reflecting AOC’s enormous engagement. AOC rarely posts about the Economy; Cuban almost exclusively does, and Cuban averages 139 likes per post. Once we account for who’s posting, the picture shifts considerably.</p>

<p><img src="/images/bluesky-regression-likes-adj.png" alt="" /></p>

<p>With account fixed effects added, R² jumps from 18.1% to 61.9% — the vast majority of the variance in likes is explained by <em>who</em> posted, not <em>what</em> they posted about. But the content effects don’t disappear; they just reorganize.</p>

<p><strong>Politics &amp; Elections</strong> (0.74), <strong>News &amp; Current Events</strong> (0.65), <strong>Social Issues &amp; Justice</strong> (0.57), <strong>Entertainment &amp; Pop Culture</strong> (0.39), <strong>Humor &amp; Satire</strong> (0.38), and <strong>Trump &amp; MAGA Criticism</strong> (0.30) all remain positive and significant even after controlling for account. Within any given account, these content types generate more likes than a baseline personal post.</p>

<p>Most striking: <strong>Economy &amp; Business</strong> goes from −1.96 to −0.002 (effectively zero, p = 0.99). The raw negative effect was entirely due to compositional differences between accounts — Cuban is the economy poster, and Cuban gets very few likes. Once you control for the account posting it, economics content performs no differently than personal content. The category itself isn’t the problem; the platform just happens to have its economics-focused account underperform on likes.</p>

<p><strong>Science &amp; Technology</strong> also loses its negative coefficient entirely once account is controlled (it becomes +0.25, non-significant). Science content doesn’t hurt engagement; it just happens to be posted by accounts that underperform on likes overall.</p>

<hr />

<h2 id="does-timing-matter">Does Timing Matter?</h2>

<p><img src="/images/bluesky-likes-by-weekday.png" alt="" /></p>

<p>The weekly pattern is clear. <strong>Friday is the best day to post</strong> at 8,522 average likes, followed by Tuesday (6,648), Wednesday (6,263), and Saturday (5,384). <strong>Sunday is the worst day</strong> at 3,325 average likes — about 60% lower than Friday’s average.</p>

<p>The spread across days is meaningful but not enormous compared to the account-level differences. Moving from Sunday to Friday would roughly double your average likes in this dataset. Moving from NPR to AOC would multiply them by about 380.</p>

<p><img src="/images/bluesky-engagement-scatter.png" alt="" /></p>

<p>The scatter plot shows every original post (log scale) colored by account. The AOC cluster sits visibly higher and to the right than every other account — more likes <em>and</em> more replies. The media organizations (NYT, NPR) cluster near the bottom. Most posts across all accounts sit in the low-engagement zone regardless of content type; the viral outliers are rare and concentrated in a small number of accounts.</p>

<hr />

<h2 id="the-public-dataset">The Public Dataset</h2>

<p>The full classified dataset — 2,500 posts, 10 accounts, 9 binary category columns, engagement metrics — is available at <strong><a href="https://huggingface.co/datasets/chrissoria/bluesky-top10-classified">chrissoria/bluesky-top10-classified</a></strong> on Hugging Face.</p>

<p>Each row is a post with columns for: <code class="language-plaintext highlighter-rouge">account_name</code>, <code class="language-plaintext highlighter-rouge">account_handle</code>, <code class="language-plaintext highlighter-rouge">timestamp</code>, <code class="language-plaintext highlighter-rouge">social_media_input</code> (the post text), <code class="language-plaintext highlighter-rouge">likes</code>, <code class="language-plaintext highlighter-rouge">replies</code>, <code class="language-plaintext highlighter-rouge">reposts</code>, <code class="language-plaintext highlighter-rouge">is_repost</code>, <code class="language-plaintext highlighter-rouge">category_1</code> through <code class="language-plaintext highlighter-rouge">category_9</code>, and several derived fields including <code class="language-plaintext highlighter-rouge">post_length</code>, <code class="language-plaintext highlighter-rouge">contains_url</code>, and <code class="language-plaintext highlighter-rouge">contains_image</code>. All classification was done by GPT-4o-mini in a single-pass, no-ensemble run. For research applications requiring higher accuracy, a multi-model ensemble is straightforward to add.</p>

<p>A few things you could do with this dataset that I haven’t:</p>
<ul>
  <li><strong>Sentiment analysis</strong> by account — which accounts skew optimistic vs. cynical?</li>
  <li><strong>Topic evolution over time</strong> — has the distribution shifted as the news cycle changed?</li>
  <li><strong>Reply rate vs. like rate</strong> — some content generates conversation without generating likes; which category does that?</li>
  <li><strong>Cross-platform comparison</strong> — the same 10 accounts presumably post on other platforms; how does Bluesky compare to X or Instagram in terms of what they share?</li>
</ul>

<hr />

<h2 id="how-to-run-it-yourself">How to Run It Yourself</h2>

<p>cat-vader handles the data pull and classification in a single call. Here’s the full workflow for replicating this analysis on any Bluesky account:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">catvader</span> <span class="k">as</span> <span class="n">cv</span>

<span class="n">results</span> <span class="o">=</span> <span class="n">cv</span><span class="p">.</span><span class="n">classify</span><span class="p">(</span>
    <span class="n">sm_source</span><span class="o">=</span><span class="s">"bluesky"</span><span class="p">,</span>
    <span class="n">handle</span><span class="o">=</span><span class="s">"aoc.bsky.social"</span><span class="p">,</span>   <span class="c1"># any Bluesky handle
</span>    <span class="n">sm_posts</span><span class="o">=</span><span class="mi">250</span><span class="p">,</span>                <span class="c1"># number of recent posts to fetch
</span>    <span class="n">categories</span><span class="o">=</span><span class="p">[</span>
        <span class="s">"Politics &amp; Elections"</span><span class="p">,</span>
        <span class="s">"Trump &amp; MAGA Criticism"</span><span class="p">,</span>
        <span class="s">"Social Issues &amp; Justice"</span><span class="p">,</span>
        <span class="s">"News &amp; Current Events"</span><span class="p">,</span>
        <span class="s">"Entertainment &amp; Pop Culture"</span><span class="p">,</span>
        <span class="s">"Humor &amp; Satire"</span><span class="p">,</span>
        <span class="s">"Science &amp; Technology"</span><span class="p">,</span>
        <span class="s">"Economy &amp; Business"</span><span class="p">,</span>
        <span class="s">"Personal &amp; Lifestyle"</span><span class="p">,</span>
    <span class="p">],</span>
    <span class="n">description</span><span class="o">=</span><span class="s">"Social media posts from a public Bluesky account"</span><span class="p">,</span>
    <span class="n">api_key</span><span class="o">=</span><span class="s">"your-openai-api-key"</span><span class="p">,</span>
    <span class="n">user_model</span><span class="o">=</span><span class="s">"gpt-4o-mini"</span><span class="p">,</span>
<span class="p">)</span>

<span class="n">results</span><span class="p">.</span><span class="n">to_csv</span><span class="p">(</span><span class="s">"bluesky_classified.csv"</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
</code></pre></div></div>

<p>The output is a DataFrame with one row per post and binary columns for each category. From there, any standard analysis pipeline applies — R, Python, whatever you prefer.</p>

<p>One difference from the Threads workflow: Bluesky doesn’t require OAuth or a developer account. The public API is unauthenticated for reading public posts. You just need an OpenAI (or other provider) key for the classification step.</p>

<hr />

<p>The consistent finding across both the Threads analysis and this one is that <em>who</em> is posting matters far more than <em>what</em> they post. Account identity — follower count, posting frequency, platform reputation — explains more of the variance in engagement than any content category. But content is not irrelevant: within any given account, political and social posts consistently outperform baseline personal posts, and that effect survives account controls.</p>

<p>For Bluesky specifically, the platform’s current character as a haven for politically engaged progressives shows up plainly in the data. Even The Onion — a satirical outlet that theoretically covers everything — lands 81% of its posts in Humor &amp; Satire while still earning engagement that tracks the political intensity of the moment. The platform has a distinct topical gravity, and the accounts doing best are the ones whose content aligns with it.</p>

<p>If you build something interesting with the dataset or the cat-vader pipeline, reach out at <a href="mailto:chrissoria@berkeley.edu">chrissoria@berkeley.edu</a>.</p>]]></content><author><name>Contact Information</name><email>chrissoria@berkeley.edu</email></author><category term="LLM" /><category term="social media" /><category term="bluesky" /><category term="cat-vader" /><category term="NLP" /><category term="open source" /><summary type="html"><![CDATA[]]></summary></entry></feed>