<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Computational Social Science on Hunter Heidenreich | Senior AI Research Scientist</title><link>https://hunterheidenreich.com/categories/computational-social-science/</link><description>Recent content in Computational Social Science on Hunter Heidenreich | Senior AI Research Scientist</description><image><title>Hunter Heidenreich | Senior AI Research Scientist</title><url>https://hunterheidenreich.com/img/avatar.webp</url><link>https://hunterheidenreich.com/img/avatar.webp</link></image><generator>Hugo -- 0.163.3</generator><language>en-US</language><copyright>2026 Hunter Heidenreich</copyright><lastBuildDate>Sat, 22 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://hunterheidenreich.com/categories/computational-social-science/index.xml" rel="self" type="application/rss+xml"/><item><title>The Most Connected State Pair Is a PDF Setting</title><link>https://hunterheidenreich.com/posts/most-connected-pair-of-states/</link><pubDate>Sat, 22 Aug 2026 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/most-connected-pair-of-states/</guid><description>A CC0 corpus of US state legislation got its text-reuse cutoff from a parenthetical example. Classifying all 2,968,117 pairs above it, 26.46% are PDF artifacts.</description><content:encoded><![CDATA[<p>State legislatures copy each other. They copy from each other&rsquo;s bills, and they
copy from the interest groups that write model legislation and shop it around
statehouses. This is not a fringe phenomenon nor is it a partisan one: when
somebody <a href="https://www.kdd.org/kdd2016/papers/files/adf0831-burgessA.pdf">built a tool to measure
it</a>, a
conservative umbrella group and a liberal one had almost
identical hit rates (~9%). It is simply how a lot of policy travels in
America.</p>
<p>Measuring it requires finding bills that share text. Political scientists do this to
estimate how policy diffuses between states; journalists have done it to
<a href="https://publicintegrity.org/politics/state-politics/copy-paste-legislate/how-we-uncovered-10000-times-lawmakers-introduced-copycat-model-bills-and-why-it-matters/">identify model bills moving between
statehouses</a>.
A method everyone in this lineage uses is the one biologists use to align gene
sequences (<a href="https://en.wikipedia.org/wiki/Smith%E2%80%93Waterman_algorithm">Smith-Waterman local
alignment</a>):
take two bills, find the longest run of matching words, score it for similarity.</p>
<p>Because this is unsupervised, you&rsquo;ll need a cutoff.
Score everything against everything and you have hundreds
of millions of bill pairs with a number attached; somewhere on that scale is the
line between coincidence and copying. <strong>Where you put that line is where every
downstream finding comes from</strong>, because every count of how much two states share
is computed over the pairs above it.</p>
<p>Here is the scale, and here is a line:</p>















<figure class="post-figure center ">
    <img src="/img/most-connected-pair-of-states/score-scale.svg"
         alt="A bar chart of all 228 million scored bill pairs bucketed by alignment score, on a logarithmic count axis, with a dashed vertical rule marking the cutoff of 50. The two buckets below the rule hold 169 million and 53 million pairs; everything above it is orders of magnitude smaller."
         title="A bar chart of all 228 million scored bill pairs bucketed by alignment score, on a logarithmic count axis, with a dashed vertical rule marking the cutoff of 50. The two buckets below the rule hold 169 million and 53 million pairs; everything above it is orders of magnitude smaller."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Almost everything scores near zero, which is what you would expect. The interesting population is the thin tail to the right.</figcaption>
    
</figure>

<p>The line sits on the steep slope rather than out in the quiet part of the
distribution, so moving it a little moves the population a lot: 53 million pairs
sit in the bucket immediately below it.</p>
<h2 id="data-and-a-threshold">Data and a Threshold</h2>
<p>This data is from a <a href="https://doi.org/10.7910/DVN/CZ25GF">CC0 corpus of state bills</a>.
<a href="https://doi.org/10.1111/psj.12257">The accompanying paper&rsquo;s</a> conclusion advises future
scholars to work in the upper quantiles, and says that for searching out bill pairs &ldquo;this can be done
through thresholding on high alignment scores <strong>(e.g., 50+)</strong> to indicate the
presence of highly similar policy proposals in legislation.&rdquo;
That said, they acknowledge that such thresholding &ldquo;may discard informative variation,&rdquo; and assert that
high-score thresholding &ldquo;would focus the analysis on bill pairs
that reliably contain very similar policy enactments, and pose little risk of a
false positive.&rdquo;</p>
<p>That is a balanced statement about high-score thresholding in
general, not necessarily a recommendation of 50.
Read strictly, that advice is about <strong>searching out</strong> bill pairs: producing
candidates worth a human&rsquo;s attention. Anybody who opens a candidate and finds a
PostScript block will throw it away, which is a fair defence of the sentence as
written.</p>
<p>Unfortunately, it stops working at scale. The cutoff selects 2,968,117 pairs, which is not a set
anybody reads, and the point of a scored corpus is that the score stands in for
the reading. So the question is what the cutoff hands you when nobody looks.</p>
<p>I classified the aligned text of every one of those <strong>2,968,117 pairs</strong>.
<strong>785,330 of them, 26.46%, are not legislative text at all.</strong></p>
<h2 id="what-is-actually-up-there">What Is Actually Up There</h2>
<p>They are page furniture: the line numbers printed down a page margin, the rule
that separates a bill&rsquo;s summary from its body, and a block of PDF printer
settings that was never meant to be read by anyone. Text extraction turned them
into words. Two bills produced by the same software then &ldquo;share&rdquo; those words, and
the aligner scores it like any other match.</p>















<figure class="post-figure center ">
    <img src="/img/most-connected-pair-of-states/score-distribution.svg"
         alt="The same bar chart of all 228 million scored bill pairs by alignment score, now with each bar split into legislative text in blue and page furniture in orange. The buckets below the cutoff are almost entirely blue. Every bucket above it carries a substantial orange segment, and the 250 to 499 bucket is 75 percent orange."
         title="The same bar chart of all 228 million scored bill pairs by alignment score, now with each bar split into legislative text in blue and page furniture in orange. The buckets below the cutoff are almost entirely blue. Every bucket above it carries a substantial orange segment, and the 250 to 499 bucket is 75 percent orange."
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption"><strong>Below the line, 0.21% of pairs are page furniture. Above it, 26.46%.</strong> The cutoff does not merely fail to screen the artifact out. It enriches for it by a factor of <strong>127</strong>, because page furniture is precisely what the instrument rewards: a long, exact, contiguous match.</figcaption>
    
</figure>

<p>That is the part I did not expect. The artifact is not sprinkled through the
corpus and then swept up incidentally by the cutoff. <strong>It is concentrated almost
entirely in the region the cutoff selects.</strong> A block of printer settings shared
between two documents is the most perfect match in the whole corpus.</p>
<h2 id="it-manufactures-edges">It Manufactures Edges</h2>
<p>If the artifact were spread evenly it would inflate a denominator and leave the
ranking of state pairs alone. It is not spread evenly. A quirk of drafting or
scanning software belongs to a <strong>jurisdiction</strong>, so the damage lands on the pairs
that jurisdiction belongs to.</p>
<blockquote>
<p><strong>Ranked by how many above-threshold pairs they share, the most connected pair
of states in this data is Arizona and Nevada, by a factor of nine over the
runner-up. 99.9% of those pairs are two PDFs agreeing about their printer
settings.</strong></p>
</blockquote>
<table>
	<thead>
			<tr>
					<th style="text-align: right">rank</th>
					<th>dyad</th>
					<th style="text-align: right">pairs</th>
					<th style="text-align: right">artifact share</th>
					<th style="text-align: right">rank after removal</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td style="text-align: right">1</td>
					<td><code>az-nv</code></td>
					<td style="text-align: right">460,447</td>
					<td style="text-align: right"><strong>99.9%</strong></td>
					<td style="text-align: right"><strong>754</strong></td>
			</tr>
			<tr>
					<td style="text-align: right">2</td>
					<td><code>hi-md</code></td>
					<td style="text-align: right">48,994</td>
					<td style="text-align: right"><strong>94.5%</strong></td>
					<td style="text-align: right"><strong>201</strong></td>
			</tr>
			<tr>
					<td style="text-align: right">3</td>
					<td><code>hi-ks</code></td>
					<td style="text-align: right">47,788</td>
					<td style="text-align: right"><strong>96.0%</strong></td>
					<td style="text-align: right"><strong>302</strong></td>
			</tr>
			<tr>
					<td style="text-align: right">4</td>
					<td><code>il-ms</code></td>
					<td style="text-align: right">43,295</td>
					<td style="text-align: right">0.0%</td>
					<td style="text-align: right">1</td>
			</tr>
			<tr>
					<td style="text-align: right">5</td>
					<td><code>nj-ny</code></td>
					<td style="text-align: right">41,433</td>
					<td style="text-align: right">0.02%</td>
					<td style="text-align: right">2</td>
			</tr>
			<tr>
					<td style="text-align: right">6</td>
					<td><code>hi-wi</code></td>
					<td style="text-align: right">37,767</td>
					<td style="text-align: right"><strong>98.8%</strong></td>
					<td style="text-align: right"><strong>856</strong></td>
			</tr>
	</tbody>
</table>
<p>Strip the artifacts and Arizona-Nevada falls from rank 1 to rank 754 of 1,247.
Nine of the top twenty dyads leave the top 100.</p>
<p><strong>Two caveats on that table:</strong></p>
<ul>
<li>Ranking by raw pair count rewards states with many
bills and good text coverage, and coverage in this corpus varies by 100
percentage points across states. This means the ranking is volume-sensitive before any
artifact enters it.</li>
<li>The removal column only subtracts,
so it cannot show a dyad crossing <em>into</em> the top 100 from underneath.</li>
</ul>
<p><strong>And one thing I am not claiming.</strong> This ranking is mine. The paper&rsquo;s own dyadic
validation regresses an aggregate alignment score on diffusion ties across 1,225
state pairs; it does not rank dyads by above-threshold count, and I have not
re-estimated any published result. I would expect the contamination to survive a
change of statistic, since 460,447 pairs at roughly 440 apiece is a great deal of
mass in any sum over <code>az-nv</code>, but I have not run that version and it is not what
the census shows.</p>
<p>What the census shows is narrower and needs no model: threshold this deposit at
a number its own paper offers, and the highest-degree edge in the resulting
network is an extraction artifact.</p>
<h2 id="where-it-comes-from">Where It Comes From</h2>
<p>The blocks are not spread across the corpus. Scanning all 571,554 bills finds
them in 3,551 Arizona bills, 3,178 Nevada bills, one Delaware bill, and nowhere
else. So I fetched the original PDFs from both legislatures and re-extracted
them.</p>
<p><strong>These PDFs carry an embedded file attachment holding the Acrobat Distiller job
options used to produce them</strong>, Flate-compressed inside a <code>/Type /EmbeddedFile</code>
stream. For one Arizona bill, decompressing that attachment gives 14,581 bytes
against the 14,580 characters sitting in the corpus for that same bill. Opening
<code>&lt;&lt;</code>, closing <code>&gt;&gt; setpagedevice</code>, indentation and all 220 newlines included. The
two are identical once the attachment&rsquo;s trailing newline comes off.</p>
<p><strong>The corpus&rsquo;s text extraction emitted PDF file attachments as document text.</strong>
Not a font problem, not an encoding problem, and not a property of the source
documents. <a href="https://poppler.freedesktop.org/"><code>pdftotext</code></a> walks page content
and ignores attachments, which is why
it reproduces the block <strong>0 times in 92</strong> from documents that demonstrably
contain it.</p>
<p>Two details make this worse than a scoring problem.
Smith-Waterman is not a cheap algorithm to run.
So to reduce the number of bills one runs this alignment scoring for,
resesarchers first perform a coarse retrieval stage to pre-filter comparisons
to those with the highest similarity as determined by bag-of-words methods (like BM-25).
The block also <strong>wins the
retrieval</strong>: a bill carrying one spends 15.00% of its
candidate slots on other bills carrying one, against a 1.36% base rate, and a
candidate that retrieval never returned was never scored at all.</p>
<p>The paper anticipated the general worry. Bills are compared against the 500
nearest neighbours Elasticsearch returns, and the authors checked that cap by
re-running at 1,000, concluding it introduces no selection bias &ldquo;because the set
of bills that result in modest or large text reuse scores is always smaller than
500.&rdquo; That reasoning holds when the top 500 is filled by candidates competing on
legislative language. <strong>What the crowding number shows is a population where the
premise is doubtful</strong>: across the 6,700 queries carrying a distiller block, an
elevenfold enrichment means a large share of those slots is won on a string that
is not language at all. I have not re-run the authors&rsquo; check, so this is a reason
to think it deserves re-running on the artifact-carrying subset, not a finding
that it fails.</p>
<p>And <strong>the bias cannot be signed</strong>. A distiller block is appended after the page
text, so excising it can only lower a score. A line-number run sits <em>between</em> two
passages of prose, so excising it joins them and the score <strong>rises</strong>, by up to 63
points.
The artifact invents reuse in the pairs it creates and conceals reuse in the
pairs it interrupts.</p>
<h2 id="the-problem-no-cleaning-fixes">The Problem No Cleaning Fixes</h2>
<p>Everything above is a defect. A better extractor and a screening pass fix all of
it, and anything built on stripped text is recoverable.</p>
<p>The pairs that survive every screen have a separate problem, and this one is a
property of the measure rather than of this corpus. It survives perfect text.</p>
<p><strong>Two states implementing the same federal statute share text word for word, and
neither one copied the other.</strong> The alignment is exact. The reuse is real. It
just does not run between the two states in the edge.</p>
<h3 id="who-did-the-reading">Who Did the &ldquo;Reading&rdquo;</h3>
<p>I tried a language model first.
<a href="https://huggingface.co/MiniMaxAI/MiniMax-M3">MiniMax M3</a> labelled every prose
pair in my
sample, three times over, and on the 477 where all three passes parsed, its
verdicts flipped on <strong>33.5%</strong>.</p>
<p>Where it broke is the useful part. The instability was worst exactly at the bar,
around 57% stable in the 50 to 99 band against 100% above 500, which left two
readings the run could not separate: either the model was unreliable on hard
cases, or pairs near the bar are genuinely undecidable from the text.</p>
<p>So I read 80 of the pairs again on a blinded page, stratified toward the 50 to
99 band where the instability lived, with score, bill ids and jurisdictions
hidden. And so did Claude Opus, on the identical page.
Whereas M3 had been the arms-length instrument, prompted and run
in batch, Claude was the model I had been working the audit with in Claude Code.
It knew my hypotheses and had already seen some of these spans.</p>
<p><strong>The agreement number below is not an inter-rater statistic.</strong>
It is a person and a model that shared a hypothesis, labelling the same
page. I am reporting it because the structure of the disagreement turned out to
matter more than its size, and because the answer to the question the M3 run
could not settle came out of it: agreement held at <strong>77.4%</strong> in the 50 to 99
band, roughly flat where M3&rsquo;s own stability had collapsed. Pairs near the bar are
decidable. The unreliable instrument was the model that kept changing its mind.</p>
<table>
	<thead>
			<tr>
					<th>what the pair turned out to be</th>
					<th style="text-align: right">pairs</th>
					<th></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>shared source by <strong>either</strong> standard</td>
					<td style="text-align: right">52</td>
					<td>65%</td>
			</tr>
			<tr>
					<td>generic formula for <strong>both</strong></td>
					<td style="text-align: right">9</td>
					<td>11%</td>
			</tr>
			<tr>
					<td>federal or uniform origin, <strong>named by Claude Opus</strong></td>
					<td style="text-align: right">18</td>
					<td><strong>23%</strong></td>
			</tr>
			<tr>
					<td>I could not tell, Claude called it shared source</td>
					<td style="text-align: right">1</td>
					<td>1%</td>
			</tr>
	</tbody>
</table>
<p><strong>No reading makes this class mostly boilerplate.</strong> The stricter reading still
puts two thirds of it on unambiguous shared source. The 11% both call generic
is what you would guess: enacting clauses, appropriation formulae, quorum
boilerplate, bill titles on unrelated subjects. The pairs that survive the
artifact screen are, in the main, real reuse.</p>
<p>The third row is the finding and the weakest row in the table.
Every pair I called shared source, Claude did too. The
split is not about where a line was drawn. Instead, the model could name an upstream
source and I could not.
Text you cannot place reasonably looks like it might be generic.</p>
<h3 id="the-names-are-the-result-not-the-labels">The Names Are the Result, Not the Labels</h3>
<p>A name is checkable in a way a label is not.</p>
<p>Claude named 37 upstream sources across the 80 pairs. <strong>I checked eight of them
against published law</strong>, choosing the ones the finding leans on hardest:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><strong>Verified verbatim</strong></td>
					<td><a href="https://www.law.cornell.edu/uscode/text/18/2510">18 U.S.C. 2510(12)</a>; FDCPA <a href="https://www.law.cornell.edu/uscode/text/15/1692k">15 U.S.C. 1692k(c)</a>; Model Penal Code 223.1(2)(c); <a href="https://www.law.cornell.edu/uscode/text/8/1611">8 U.S.C. 1611(b)</a>, including its three-clause structure; <a href="https://www.juvenilecompact.org/compact-statute/article-iii-the-interstate-commission-for-juveniles">Interstate Compact for Juveniles article III</a>, across seven state codes; <a href="https://www.uniformlaws.org/committees/community-home?CommunityKey=a5b28eee-3971-498c-bfd9-6e43118ae346">Uniform Unsworn Foreign Declarations Act</a> definitions, across five; the $500 / $5,000 portable electronics insurance penalty structure, in Delaware and Massachusetts codes, though the NAIC model number Claude named was not confirmed</td>
			</tr>
			<tr>
					<td><strong>Partial</strong></td>
					<td><a href="https://www.ecfr.gov/current/title-49/section-383.51">49 CFR 383.51</a>, confirmed as the governing rule and the right offence class, exact wording not recovered</td>
			</tr>
			<tr>
					<td><strong>Refuted</strong></td>
					<td>none</td>
			</tr>
	</tbody>
</table>
<p><strong>Seven verbatim, one partial, none refuted, and all eight sit in the disputed
column.</strong> That is the result. Text that a careful reader calls generic formula is
sometimes a named federal act reproduced word for word, and unlike every
similarity score in this corpus, that is a claim a stranger can destroy with a
search engine.</p>
<p><strong>The twenty-nine unchecked names are not evidence</strong>, and the reason is on the
record. On a control pair reading <em>this act shall be known and may be cited as
the school protection act</em>, M3 confidently named an ALEC model act, the Liberty
Preservation Act, which has nothing to do with it. A model asked to name a source
will produce a plausible one whether or not it exists. The eight are worth
something precisely because they were checked; the rest are leads.</p>
<h3 id="the-part-that-survives-a-clean-corpus">The Part That Survives a Clean Corpus</h3>
<p>A threshold on text similarity is selecting two different phenomena and cannot
tell them apart. States adopting a private model act is policy diffusion, which
is what the measure was built to find. States independently implementing the same
federal statute is mandated uniformity, which is a different causal story with the
same textual signature. The alignment is equally exact in both cases, which is
precisely why no score separates them. A higher threshold does not help. A better
aligner does not help. Cleaning the text does not help, because there is nothing
wrong with the text.</p>
<p>And the error has structure, which is what makes it bite. States differ in how
much federal law they implement and how fast, so <strong>a diffusion network built on
this measure counts federal conformity as interstate influence</strong>, and it does so
unevenly across exactly the dyads people draw conclusions from.</p>
<p><strong>None of which is news to the diffusion literature.</strong> Vertical influence,
federal mandates and common exposure are long-standing confounds in that field,
and there are methods built to handle them. The narrow claim is the one worth
making, and it is about the instrument: <strong>a text-reuse score offers no purchase
on the distinction at all</strong>, because the two cases have an identical textual
signature. Whatever a researcher does about common exposure, they have to do it
from outside this measure, and a network drawn straight from alignment scores
carries the confound with no way to see it.</p>
<p>The fix is a <strong>labelled source</strong>: to know whether copying ran between the two
jurisdictions in your edge, you have to know <em>what</em> was copied.</p>
<h3 id="how-far-that-goes-and-how-far-it-does-not">How Far That Goes, and How Far It Does Not</h3>
<p><strong>The sample is not the population.</strong> The 80 pairs were drawn to over-represent
the 50 to 99 band, so the 23% is a rate within a deliberately skewed draw and I
would not quote it as a corpus rate. Read it as context for the eight verified
names rather than as the finding.</p>
<p><strong>There is one human in this design.</strong> Me, not blind, holding the hypothesis
while labelling. Everything else in the reading pipeline was a model. What that
design can establish is bounded, and what it did establish is the checkable half:
eight names, seven exact.</p>
<p><strong>What the 80 pairs support</strong> is that the distinction exists, that it is not
rare, and that where it has been tested it holds. How large it is corpus-wide is
unmeasured, and a second human annotator who had never read this audit is the
thing that would change it. That is not a run I can do, so the
<a href="https://github.com/hunter-heidenreich/page-furniture/blob/main/observations/annotate-2026-08-06.html">blinded page</a>
and <a href="https://github.com/hunter-heidenreich/page-furniture/blob/main/results/adjudication-2026-08-22.md">both sets of labels</a>
ship with the code, and somebody else can.</p>
<h2 id="so-would-a-language-model-have-helped">So, Would a Language Model Have Helped?</h2>
<p>I came at this wanting to know whether a language model was useful for
legislative text reuse. Having spent the time, the answer is yes, and not where
I expected.</p>
<p>The contamination needed no model at all. It is a defect: a few dozen lines of
pattern matching find every class, removal is lossless because each one is
self-delimiting, and anything built on stripped text is recoverable.</p>
<p>The uniformity confound is the opposite. It is not removable at any threshold and
a better similarity score will not touch it. What would separate it is knowing
what was copied, which is a provenance question, and provenance is the kind of
question a language model is actually good at.</p>
<p>It was the one thing both models did well here. Asked to name upstream sources,
M3 produced the Uniform Controlled Substances Act, the Streamlined Sales and Use
Tax Agreement, the Uniform Vehicle Code, 18 U.S.C. 2510, and those names were
<strong>more durable than its own verdicts</strong>, agreeing across passes 77% of the time
against 66.5% for the labels. None of that batch was checked against published
law. The eight that were checked came from the blinded run and seven were exact.</p>
<p>With the condition from the section above attached, and it is not a small one:
the behaviour that produces a correct name also produces the ALEC misfire, and
nothing in the output tells you which one you are holding. Only a check does.</p>
<p>That is the whole thing in miniature:</p>
<blockquote>
<p>A similarity label is an opinion. A named source is a claim. Only the second
kind can be refuted.</p>
</blockquote>
<p>A wrong name announces itself the moment somebody checks it. A wrong similarity
score is invisible forever. The second failure mode is the one you want.</p>
<p>So the honest next artifact here is not a better detector. It is a <strong>labelled
source corpus</strong>, built by something that can propose a name and verified by
somebody who checks it.</p>
<h2 id="if-you-use-this-deposit">If You Use This Deposit</h2>
<p>Strip all three classes before thresholding. Each is self-delimiting, so removal
is exact rather than heuristic.</p>
<table>
	<thead>
			<tr>
					<th>class</th>
					<th>what to look for</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Distiller blocks</td>
					<td>self-delimiting, <code>&lt;&lt;</code> to <code>&gt;&gt; setpagedevice</code></td>
			</tr>
			<tr>
					<td>Line-number runs</td>
					<td>twelve or more consecutive <strong>bare</strong> integers</td>
			</tr>
			<tr>
					<td>Typographic rules</td>
					<td>twelve or more consecutive tokens of <code>-</code>, <code>_</code>, <code>=</code>, <code>*</code></td>
			</tr>
	</tbody>
</table>
<p>Three things that are easy to get wrong. <strong>Do not just exclude Arizona and
Nevada</strong>, because the other two classes cover more dyads than the distiller block
does. <strong>Treat Hawaii separately</strong>, because its text is OCR-damaged underneath the
artifact, that damage is what its source documents actually say, and neither
removal nor re-extraction fixes it. And <strong>before removing a run, check that it
ascends by one</strong>, because a run that ascends on every second position is a
statutory rate schedule with a counter threaded through it, and excising it
deletes enacted numbers.</p>
<p><strong>Do not trust a high threshold to be safe either.</strong> The most reassuring number
here, that the band above 500 is 99.1% clean, is an accident: the distiller blocks
stop scoring at 442 only because Arizona ran Distiller 8.2.5 and Nevada ran 6.0,
so the two job-options files agree only through their shared prefix. Two
jurisdictions on the same version would share a byte-identical 14,500-character
block, which aligns into the thousands.</p>
<h2 id="the-full-audit">The Full Audit</h2>
<p>The writeup behind all of this is a technical report: the four independent
instruments, the removal experiment and its label-blind control, the scrape-era
census that explains why the damage partitions by legislative session rather than
by state, the limitations including a cluster bootstrap that widens my own
headline interval by a factor of five, and an appendix of the four things earlier
drafts got wrong. It is at
<a href="https://github.com/hunter-heidenreich/page-furniture/blob/main/REPORT.md">REPORT.md in the page-furniture repo</a>,
alongside the screen itself and the dated results file behind every number here.</p>
<p><strong>The 80-pair adjudication is in there too.</strong> The
<a href="https://github.com/hunter-heidenreich/page-furniture/blob/main/results/adjudication-2026-08-22.md">per-pair record</a>
has every pair, both labels, the upstream act Claude named where it named one,
and which eight I checked against published law. The 29 unchecked names are
published as well, because a name nobody has checked is a lead, and I would
rather ship it as one than quietly drop it.</p>
<h2 id="attribution">Attribution</h2>
<p>The corpus was collected by <a href="https://openstates.org/">Open States</a> and assembled
by Burgess et al. It is CC0; US legislative text is uncopyrightable at origin
under <a href="https://www.law.cornell.edu/supremecourt/text/18-1150"><em>Georgia v. Public.Resource.Org</em>
(2020)</a>.</p>
<blockquote>
<p>Burgess, Matthew, Eugenia Giraudy, Julian Katz-Samuels, Joe Walsh, Derek
Willis, Lauren Haynes, and Rayid Ghani. &ldquo;The Legislative Influence Detector:
Finding Text Reuse in State Legislation.&rdquo; <em>Proceedings of the 22nd ACM SIGKDD
International Conference on Knowledge Discovery and Data Mining</em>, 57-66, 2016,
<a href="https://doi.org/10.1145/2939672.2939697">doi:10.1145/2939672.2939697</a>.</p>
</blockquote>
<p>The deposit measured here is Linder, Fridolin J., &ldquo;Replication Data for:
Measuring Policy Similarity Through Bill Text Reuse&rdquo;, Harvard Dataverse,
<a href="https://doi.org/10.7910/DVN/CZ25GF">doi:10.7910/DVN/CZ25GF</a>, accompanying
Linder, Desmarais, Burgess and Giraudy, &ldquo;Text as Policy: Measuring Policy
Similarity through Bill Text Reuse&rdquo;, <em>Policy Studies Journal</em>,
<a href="https://doi.org/10.1111/psj.12257">doi:10.1111/psj.12257</a>. The prior art is
Citron, Daniel T. and Paul Ginsparg, &ldquo;Patterns of text reuse in a scientific
corpus&rdquo;, <em>PNAS</em> 112(1):25-30, 2015,
<a href="https://doi.org/10.1073/pnas.1415135111">doi:10.1073/pnas.1415135111</a>.</p>
]]></content:encoded></item><item><title>Classifying Congressional Bills with Machine Learning</title><link>https://hunterheidenreich.com/posts/congressional-bill-policy-area-classification/</link><pubDate>Wed, 21 Feb 2024 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/congressional-bill-policy-area-classification/</guid><description>Testing ML classification of congressional bills by policy area. Comparing Naive Bayes, Logistic Regression, and XGBoost on legislative text.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>This post explores machine learning approaches for classifying congressional bills by policy area, using data from the 115th to 117th Congresses (2017-2023). We&rsquo;ll examine:</p>
<ul>
<li>The fundamentals of bill classification</li>
<li>Traditional machine learning models as baselines</li>
<li>Performance analysis across different time periods and policy domains</li>
</ul>
<p>This work establishes baselines for future deep learning approaches to legislative text classification.</p>
<p><em>This post builds on the data foundation established in <a href="/posts/us-117th-congress-data-exploration/">Exploring the 117th U.S. Congress</a>.</em></p>
<h3 id="motivation">Motivation</h3>
<p>Automatically classifying congressional bills by policy area has practical value for researchers, journalists, and citizens who need to navigate thousands of bills each Congress. Machine learning can help identify patterns in legislative priorities and track policy trends over time.</p>
<h2 id="data">Data</h2>
<p>The data comes from scraping <a href="https://www.congress.gov/">Congress.gov</a> for all bills from the 115th through 117th Congresses. Each bill includes:</p>
<ul>
<li>Bill ID and title</li>
<li>Summary (when available): the earliest summary provided</li>
<li>Full text (when available): the earliest text version</li>
<li>Policy area classification</li>
</ul>
<p>Our task is to predict policy area from text features:</p>
<p>$$
f(X) = \hat{y}, \quad \text{where} \quad X = { \text{title}, \text{summary}, \text{text} }, \quad \hat{y} \in { \text{policy areas} }
$$</p>
<p>The complete dataset is available at <a href="https://huggingface.co/datasets/hheiden/us-congress-bill-policy-115_117">Hugging Face: hheiden/us-congress-bill-policy-115_117</a>.</p>
<h3 id="bills-by-congress">Bills by Congress</h3>
<p>Our dataset contains the following distribution:</p>
<table>
	<thead>
			<tr>
					<th>Congress</th>
					<th>Bills</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>115th</td>
					<td>13,556</td>
			</tr>
			<tr>
					<td>116th</td>
					<td>16,601</td>
			</tr>
			<tr>
					<td>117th</td>
					<td>17,817</td>
			</tr>
			<tr>
					<td><strong>Total</strong></td>
					<td><strong>47,974</strong></td>
			</tr>
	</tbody>
</table>
<h3 id="policy-areas">Policy Areas</h3>
<p>Each bill receives a policy area label from <a href="https://www.congress.gov/">Congress.gov</a> (see <a href="https://www.congress.gov/help/field-values/policy-area">glossary</a>). The dataset includes 33 policy areas, though these classes are highly imbalanced.</p>
<p>The following table shows the number of bills in each policy area across the three Congresses:</p>
<table>
	<thead>
			<tr>
					<th>Policy Area</th>
					<th>115th</th>
					<th>116th</th>
					<th>117th</th>
					<th>Total</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Agriculture and Food</td>
					<td>312</td>
					<td>328</td>
					<td>398</td>
					<td>1,038</td>
			</tr>
			<tr>
					<td>Animals</td>
					<td>96</td>
					<td>83</td>
					<td>71</td>
					<td>250</td>
			</tr>
			<tr>
					<td>Armed Forces and National Security</td>
					<td>1,108</td>
					<td>1,337</td>
					<td>1,399</td>
					<td>3,844</td>
			</tr>
			<tr>
					<td>Arts, Culture, Religion</td>
					<td>81</td>
					<td>79</td>
					<td>103</td>
					<td>263</td>
			</tr>
			<tr>
					<td>Civil Rights and Liberties, Minority Issues</td>
					<td>175</td>
					<td>205</td>
					<td>220</td>
					<td>600</td>
			</tr>
			<tr>
					<td>Commerce</td>
					<td>312</td>
					<td>593</td>
					<td>633</td>
					<td>1,538</td>
			</tr>
			<tr>
					<td>Congress</td>
					<td>594</td>
					<td>541</td>
					<td>640</td>
					<td>1,775</td>
			</tr>
			<tr>
					<td>Crime and Law Enforcement</td>
					<td>827</td>
					<td>904</td>
					<td>1,022</td>
					<td>2,753</td>
			</tr>
			<tr>
					<td>Economics and Public Finance</td>
					<td>176</td>
					<td>210</td>
					<td>197</td>
					<td>583</td>
			</tr>
			<tr>
					<td>Education</td>
					<td>607</td>
					<td>798</td>
					<td>801</td>
					<td>2,206</td>
			</tr>
			<tr>
					<td>Emergency Management</td>
					<td>207</td>
					<td>198</td>
					<td>202</td>
					<td>607</td>
			</tr>
			<tr>
					<td>Energy</td>
					<td>316</td>
					<td>370</td>
					<td>530</td>
					<td>1,216</td>
			</tr>
			<tr>
					<td>Environmental Protection</td>
					<td>352</td>
					<td>423</td>
					<td>464</td>
					<td>1,239</td>
			</tr>
			<tr>
					<td>Families</td>
					<td>79</td>
					<td>127</td>
					<td>139</td>
					<td>345</td>
			</tr>
			<tr>
					<td>Finance and Financial Sector</td>
					<td>556</td>
					<td>611</td>
					<td>601</td>
					<td>1,768</td>
			</tr>
			<tr>
					<td>Foreign Trade and International Finance</td>
					<td>120</td>
					<td>148</td>
					<td>212</td>
					<td>480</td>
			</tr>
			<tr>
					<td>Government Operations and Politics</td>
					<td>1,008</td>
					<td>1,258</td>
					<td>1,272</td>
					<td>3,538</td>
			</tr>
			<tr>
					<td>Health</td>
					<td>1,526</td>
					<td>2,109</td>
					<td>2,276</td>
					<td>5,911</td>
			</tr>
			<tr>
					<td>Housing and Community Development</td>
					<td>142</td>
					<td>250</td>
					<td>231</td>
					<td>623</td>
			</tr>
			<tr>
					<td>Immigration</td>
					<td>398</td>
					<td>466</td>
					<td>591</td>
					<td>1,455</td>
			</tr>
			<tr>
					<td>International Affairs</td>
					<td>918</td>
					<td>1,178</td>
					<td>1,390</td>
					<td>3,486</td>
			</tr>
			<tr>
					<td>Labor and Employment</td>
					<td>348</td>
					<td>452</td>
					<td>552</td>
					<td>1,352</td>
			</tr>
			<tr>
					<td>Law</td>
					<td>109</td>
					<td>162</td>
					<td>175</td>
					<td>446</td>
			</tr>
			<tr>
					<td>Native Americans</td>
					<td>175</td>
					<td>234</td>
					<td>245</td>
					<td>654</td>
			</tr>
			<tr>
					<td>Public Lands and Natural Resources</td>
					<td>718</td>
					<td>648</td>
					<td>642</td>
					<td>2,008</td>
			</tr>
			<tr>
					<td>Science, Technology, Communications</td>
					<td>389</td>
					<td>551</td>
					<td>505</td>
					<td>1,445</td>
			</tr>
			<tr>
					<td>Social Sciences and History</td>
					<td>5</td>
					<td>6</td>
					<td>4</td>
					<td>15</td>
			</tr>
			<tr>
					<td>Social Welfare</td>
					<td>177</td>
					<td>229</td>
					<td>199</td>
					<td>605</td>
			</tr>
			<tr>
					<td>Sports and Recreation</td>
					<td>92</td>
					<td>93</td>
					<td>125</td>
					<td>310</td>
			</tr>
			<tr>
					<td>Taxation</td>
					<td>983</td>
					<td>1,156</td>
					<td>1,078</td>
					<td>3,217</td>
			</tr>
			<tr>
					<td>Transportation and Public Works</td>
					<td>492</td>
					<td>672</td>
					<td>742</td>
					<td>1,906</td>
			</tr>
			<tr>
					<td>Water Resources Development</td>
					<td>89</td>
					<td>111</td>
					<td>110</td>
					<td>310</td>
			</tr>
			<tr>
					<td>Private Legislation</td>
					<td>69</td>
					<td>71</td>
					<td>48</td>
					<td>188</td>
			</tr>
	</tbody>
</table>
<p>The class imbalance is severe: <code>Social Sciences and History</code> has only 15 bills across all three Congresses, while <code>Health</code> has 5,911 bills. This imbalance presents modeling challenges, as minority classes may lack sufficient representative samples.</p>
<h3 id="text-statistics">Text Statistics</h3>
<p>We analyzed token counts using spaCy to understand the computational requirements for each text field.</p>
<p>Title Token Statistics:</p>
<table>
	<thead>
			<tr>
					<th>Congress</th>
					<th>Average Tokens</th>
					<th>Min Tokens</th>
					<th>Max Tokens</th>
					<th>Total Tokens</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>115th</td>
					<td>12.3</td>
					<td>1</td>
					<td>167</td>
					<td>166,763</td>
			</tr>
			<tr>
					<td>116th</td>
					<td>11.3</td>
					<td>1</td>
					<td>226</td>
					<td>188,158</td>
			</tr>
			<tr>
					<td>117th</td>
					<td>11.5</td>
					<td>1</td>
					<td>272</td>
					<td>204,978</td>
			</tr>
			<tr>
					<td>All</td>
					<td>11.7</td>
					<td>1</td>
					<td>272</td>
					<td>559,419</td>
			</tr>
	</tbody>
</table>
<p>Summary Token Statistics:</p>
<table>
	<thead>
			<tr>
					<th>Congress</th>
					<th>Average Tokens</th>
					<th>Min Tokens</th>
					<th>Max Tokens</th>
					<th>Total Tokens</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>115th</td>
					<td>109.1</td>
					<td>2</td>
					<td>6,839</td>
					<td>1,479,212</td>
			</tr>
			<tr>
					<td>116th</td>
					<td>94.9</td>
					<td>2</td>
					<td>5,886</td>
					<td>1,574,732</td>
			</tr>
			<tr>
					<td>117th</td>
					<td>95.1</td>
					<td>2</td>
					<td>502</td>
					<td>1,695,276</td>
			</tr>
			<tr>
					<td>All</td>
					<td>99.0</td>
					<td>2</td>
					<td>6,839</td>
					<td>4,749,220</td>
			</tr>
	</tbody>
</table>
<p>Full Text Token Statistics:</p>
<table>
	<thead>
			<tr>
					<th>Congress</th>
					<th>Average Tokens</th>
					<th>Min Tokens</th>
					<th>Max Tokens</th>
					<th>Total Tokens</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>115th</td>
					<td>2,588.7</td>
					<td>91</td>
					<td>304,478</td>
					<td>35,092,075</td>
			</tr>
			<tr>
					<td>116th</td>
					<td>2,760.3</td>
					<td>70</td>
					<td>973,173</td>
					<td>45,824,498</td>
			</tr>
			<tr>
					<td>117th</td>
					<td>2,706.7</td>
					<td>71</td>
					<td>1,013,608</td>
					<td>48,224,757</td>
			</tr>
			<tr>
					<td>All</td>
					<td>-</td>
					<td>70</td>
					<td>1,013,608</td>
					<td>129,141,330</td>
			</tr>
	</tbody>
</table>
<p>These statistics reveal computational trade-offs:</p>
<ul>
<li><strong>Titles</strong> average ~12 tokens: computationally efficient but limited information.</li>
<li><strong>Summaries</strong> average ~100 tokens: good balance of information and efficiency.</li>
<li><strong>Full text</strong> averages ~2,700 tokens with 129M total tokens: detailed but computationally expensive. Processing this volume of text introduces real-world engineering challenges, such as memory constraints and a higher noise-to-signal ratio typical of long legal documents.</li>
</ul>
<p>We&rsquo;ll prototype with titles and summaries before considering full text, given the computational costs involved.</p>
<h2 id="evaluation-framework">Evaluation Framework</h2>
<h3 id="experimental-design">Experimental Design</h3>
<p>We train models on one Congress and test on others, creating a 3x3 evaluation grid. This setup evaluates both within-Congress performance (same session) and cross-Congress generalization (different sessions). We expect temporal drift between Congress sessions to impact performance.</p>
<h3 id="metrics-and-hyperparameter-tuning">Metrics and Hyperparameter Tuning</h3>
<p>We use weighted average F1 score to handle class imbalance, ensuring fair evaluation across all policy areas regardless of frequency.</p>
<p>For within-Congress evaluation, we report cross-validated scores. For cross-Congress evaluation, we test on the entire target Congress dataset.</p>
<p>Hyperparameter tuning uses Cross-Validation Grid Search with folds set to <code>min(3, n_samples)</code> to ensure all classes are represented. We apply the best parameters from training to test generalization across different Congresses.</p>
<h2 id="baseline-models">Baseline Models</h2>
<p>We evaluate three traditional machine learning approaches using TF-IDF vectorization:</p>
<h3 id="text-preprocessing">Text Preprocessing</h3>
<p>We convert text to numerical features using TF-IDF (term frequency-inverse document frequency), which weighs word importance by frequency within documents relative to the entire corpus. This creates normalized feature vectors suitable for machine learning classification.</p>
<h3 id="multinomial-naive-bayes">Multinomial Naive Bayes</h3>
<p>We start with Multinomial Naive Bayes as our simplest baseline. Despite its &ldquo;naive&rdquo; independence assumption between features, this model often performs surprisingly well for text classification tasks and serves as an important benchmark. If more complex models can&rsquo;t beat Naive Bayes, it signals potential issues with the approach or data.</p>
<p>The model&rsquo;s <code>feature_log_prob_</code> attribute reveals the most influential words for each policy area, providing interpretable insights into classification patterns.</p>
<p>You can see the code for training the Naive Bayes model below:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.feature_extraction.text <span style="color:#f92672">import</span> TfidfVectorizer
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.model_selection <span style="color:#f92672">import</span> GridSearchCV
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.pipeline <span style="color:#f92672">import</span> Pipeline
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.naive_bayes <span style="color:#f92672">import</span> MultinomialNB
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Create a pipeline with TF-IDF vectorizer and Multinomial Naive Bayes classifier</span>
</span></span><span style="display:flex;"><span>pipeline <span style="color:#f92672">=</span> Pipeline([
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;tfidf&#39;</span>, TfidfVectorizer(lowercase<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, dtype<span style="color:#f92672">=</span>np<span style="color:#f92672">.</span>float32)),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;clf&#39;</span>, MultinomialNB()),
</span></span><span style="display:flex;"><span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Define the parameters for grid search</span>
</span></span><span style="display:flex;"><span>parameters <span style="color:#f92672">=</span> {  
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__ngram_range&#39;</span>: [(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>), (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>), (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">3</span>)],
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>, <span style="color:#ae81ff">0.5</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__min_df&#39;</span>: (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">5</span>, <span style="color:#ae81ff">10</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;clf__alpha&#39;</span>: (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.01</span>, <span style="color:#ae81ff">0.001</span>),
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Perform grid search with cross-validation</span>
</span></span><span style="display:flex;"><span>grid_search <span style="color:#f92672">=</span> GridSearchCV(
</span></span><span style="display:flex;"><span>    pipeline,
</span></span><span style="display:flex;"><span>    parameters,
</span></span><span style="display:flex;"><span>    scoring<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;f1_weighted&#39;</span>,
</span></span><span style="display:flex;"><span>    n_jobs<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>    refit<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    cv<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>grid_search<span style="color:#f92672">.</span>fit(X_train, y_train)
</span></span></code></pre></div><h3 id="logistic-regression">Logistic Regression</h3>
<p>Logistic regression provides a natural step up in complexity from Naive Bayes. It uses the logistic function to convert linear combinations of features into probabilities, making it an excellent baseline for comparison with more sophisticated models while remaining interpretable.</p>
<p>You can see the code for training the Logistic Regression model below:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.feature_extraction.text <span style="color:#f92672">import</span> TfidfVectorizer
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.model_selection <span style="color:#f92672">import</span> GridSearchCV
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.pipeline <span style="color:#f92672">import</span> Pipeline
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.linear_model <span style="color:#f92672">import</span> LogisticRegression
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Create a pipeline with TF-IDF vectorizer and Logistic Regression classifier</span>
</span></span><span style="display:flex;"><span>pipeline <span style="color:#f92672">=</span> Pipeline([
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;tfidf&#39;</span>, TfidfVectorizer(lowercase<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, dtype<span style="color:#f92672">=</span>np<span style="color:#f92672">.</span>float32)),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;clf&#39;</span>, LogisticRegression(max_iter<span style="color:#f92672">=</span><span style="color:#ae81ff">1000</span>, random_state<span style="color:#f92672">=</span><span style="color:#ae81ff">42</span>, class_weight<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;balanced&#39;</span>)),
</span></span><span style="display:flex;"><span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Define the parameters for grid search</span>
</span></span><span style="display:flex;"><span>parameters <span style="color:#f92672">=</span> {  
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__ngram_range&#39;</span>: [(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>), (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)],
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;clf__C&#39;</span>: [<span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">10</span>],
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Perform grid search with cross-validation</span>
</span></span><span style="display:flex;"><span>grid_search <span style="color:#f92672">=</span> GridSearchCV(
</span></span><span style="display:flex;"><span>    pipeline,
</span></span><span style="display:flex;"><span>    parameters,
</span></span><span style="display:flex;"><span>    scoring<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;f1_weighted&#39;</span>,
</span></span><span style="display:flex;"><span>    n_jobs<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>,
</span></span><span style="display:flex;"><span>    refit<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    cv<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>grid_search<span style="color:#f92672">.</span>fit(X_train, y_train)
</span></span></code></pre></div><h3 id="xgboost">XGBoost</h3>
<p>We include XGBoost as our tree-based ensemble method. While XGBoost typically excels on structured tabular data, we test whether its gradient boosting approach can effectively handle TF-IDF features for text classification.</p>
<p>You can see the code for training the XGBoost model below:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.feature_extraction.text <span style="color:#f92672">import</span> TfidfVectorizer
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.model_selection <span style="color:#f92672">import</span> GridSearchCV
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> sklearn.pipeline <span style="color:#f92672">import</span> Pipeline
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> xgboost <span style="color:#f92672">import</span> XGBClassifier
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Create a pipeline with TF-IDF vectorizer and XGBoost classifier</span>
</span></span><span style="display:flex;"><span>pipeline <span style="color:#f92672">=</span> Pipeline([
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;tfidf&#39;</span>, TfidfVectorizer(lowercase<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, dtype<span style="color:#f92672">=</span>np<span style="color:#f92672">.</span>float32)),
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#39;clf&#39;</span>, XGBClassifier(use_label_encoder<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>, eval_metric<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;mlogloss&#39;</span>, objective<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;multi:softmax&#39;</span>, seed<span style="color:#f92672">=</span><span style="color:#ae81ff">42</span>, n_jobs<span style="color:#f92672">=-</span><span style="color:#ae81ff">1</span>)),
</span></span><span style="display:flex;"><span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Define the parameters for grid search</span>
</span></span><span style="display:flex;"><span>parameters <span style="color:#f92672">=</span> {  
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;tfidf__max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;clf__max_depth&#39;</span>: (<span style="color:#ae81ff">3</span>, <span style="color:#ae81ff">6</span>, <span style="color:#ae81ff">9</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;clf__n_estimators&#39;</span>: (<span style="color:#ae81ff">100</span>, <span style="color:#ae81ff">200</span>, <span style="color:#ae81ff">300</span>),
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Perform grid search with cross-validation</span>
</span></span><span style="display:flex;"><span>grid_search <span style="color:#f92672">=</span> GridSearchCV(
</span></span><span style="display:flex;"><span>    pipeline,
</span></span><span style="display:flex;"><span>    parameters,
</span></span><span style="display:flex;"><span>    scoring<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;f1_weighted&#39;</span>,
</span></span><span style="display:flex;"><span>    refit<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>    cv<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>,
</span></span><span style="display:flex;"><span>    verbose<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>,
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span>grid_search<span style="color:#f92672">.</span>fit(X_train, y_train, clf__sample_weight<span style="color:#f92672">=</span>sample_weight)
</span></span></code></pre></div><h2 id="results">Results</h2>
<p>We evaluate models on three input types:</p>
<ul>
<li><strong>Title-only</strong>: Quick prototyping with limited context</li>
<li><strong>Summary-only</strong>: Balanced information content and computational efficiency</li>
<li><strong>Full text</strong>: Maximum context with computational constraints (limited hyperparameter tuning)</li>
</ul>
<h3 id="title-only-inputs">Title-Only Inputs</h3>
<h4 id="naive-bayes">Naive Bayes</h4>
<p>Title-only Naive Bayes experiments are run with the following settings:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>sweep_nb(
</span></span><span style="display:flex;"><span>    data,
</span></span><span style="display:flex;"><span>    X_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;title&#39;</span>,
</span></span><span style="display:flex;"><span>    y_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;policy_area&#39;</span>,
</span></span><span style="display:flex;"><span>    tfidf_params<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;lowercase&#39;</span>: <span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;dtype&#39;</span>: np<span style="color:#f92672">.</span>float32,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    tfidf_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;ngram_range&#39;</span>: [(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>), (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)],
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>, <span style="color:#ae81ff">0.5</span>),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;min_df&#39;</span>: (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>, <span style="color:#ae81ff">5</span>),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    nb_params<span style="color:#f92672">=</span>{},
</span></span><span style="display:flex;"><span>    nb_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;alpha&#39;</span>: (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.01</span>, <span style="color:#ae81ff">0.001</span>),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>and the results:</p>
<pre><code>Training on Congress 115
Best score: 0.661
Refit Time: 0.570
Best parameters set:
	clf__alpha: 0.01
	tfidf__max_df: 0.05
	tfidf__min_df: 1
	tfidf__ngram_range: (1, 2)
Testing on Congress 116 F1: 0.6369760774921475
Testing on Congress 117 F1: 0.5488274400521962

Training on Congress 116
Best score: 0.677
Refit Time: 0.499
Best parameters set:
	clf__alpha: 0.01
	tfidf__max_df: 0.05
	tfidf__min_df: 1
	tfidf__ngram_range: (1, 2)
Testing on Congress 115 F1: 0.691175262953872
Testing on Congress 117 F1: 0.6798043069585031

Training on Congress 117
Best score: 0.670
Refit Time: 0.565
Best parameters set:
	clf__alpha: 0.01
	tfidf__max_df: 0.25
	tfidf__min_df: 1
	tfidf__ngram_range: (1, 2)
Testing on Congress 115 F1: 0.6168474701996426
Testing on Congress 116 F1: 0.6981574942116808

Mean fit time: 0.54 ± 0.03s
</code></pre>
<h4 id="results-summary">Results Summary</h4>
<p>The results demonstrate several key findings:</p>
<ul>
<li><strong>Fast training</strong>: Sub-second training times make this highly practical</li>
<li><strong>Solid baseline performance</strong>: F1 scores around 0.65-0.70 provide a reasonable starting point</li>
<li><strong>Consistent hyperparameters</strong>: Similar optimal settings across Congresses suggest stable patterns</li>
<li><strong>Temporal effects</strong>: Performance generally decreases when training and testing on Congresses further apart in time</li>
</ul>
<p>Training on the 116th Congress yields the best cross-Congress performance, likely due to its temporal proximity to both adjacent sessions.</p>















<figure class="post-figure center ">
    <img src="/img/nb_title_policy_area/f1s.webp"
         alt="Naive Bayes Policy Area Classification F1 Score"
         title="Naive Bayes Policy Area Classification F1 Score"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Naive Bayes F1 scores show temporal effects, with better performance between adjacent Congresses</figcaption>
    
</figure>

<p>The model learns interpretable features for each policy area. For example, Agriculture bills are strongly associated with terms like &ldquo;farm,&rdquo; &ldquo;crop,&rdquo; and &ldquo;livestock,&rdquo; while Armed Forces bills correlate with &ldquo;military,&rdquo; &ldquo;defense,&rdquo; and &ldquo;veterans.&rdquo;</p>















<figure class="post-figure center ">
    <img src="/img/nb_title_policy_area/top-Agriculture_and_Food.webp"
         alt="Naive Bayes Top Features for Agriculture and Food"
         title="Naive Bayes Top Features for Agriculture and Food"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Naive Bayes Top Features for Agriculture and Food</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/nb_title_policy_area/top-Armed_Forces_and_National_Security.webp"
         alt="Naive Bayes Top Features for Armed Forces and National Security"
         title="Naive Bayes Top Features for Armed Forces and National Security"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Naive Bayes Top Features for Armed Forces and National Security</figcaption>
    
</figure>
















<figure class="post-figure center ">
    <img src="/img/nb_title_policy_area/top-Health.webp"
         alt="Naive Bayes Top Features for Health"
         title="Naive Bayes Top Features for Health"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Naive Bayes Top Features for Health</figcaption>
    
</figure>

<h4 id="logistic-regression-1">Logistic Regression</h4>
<p>Title-only Logistic Regression experiments are run with the following settings:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>sweep_logreg(
</span></span><span style="display:flex;"><span>    data,
</span></span><span style="display:flex;"><span>    X_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;title&#39;</span>,
</span></span><span style="display:flex;"><span>    y_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;policy_area&#39;</span>,
</span></span><span style="display:flex;"><span>    tfidf_params<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;lowercase&#39;</span>: <span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;dtype&#39;</span>: np<span style="color:#f92672">.</span>float32,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    tfidf_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;ngram_range&#39;</span>: [(<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">1</span>), (<span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">2</span>)],
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    logreg_params<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_iter&#39;</span>: <span style="color:#ae81ff">1000</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;random_state&#39;</span>: <span style="color:#ae81ff">42</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;class_weight&#39;</span>: <span style="color:#e6db74">&#39;balanced&#39;</span>,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    logreg_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;C&#39;</span>: [<span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">10</span>],
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>and the results:</p>
<pre><code>Training on Congress 115
Best score: 0.704
Refit Time: 32.063
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.05
	tfidf__ngram_range: (1, 2)
Testing on Congress 116 F1: 0.6809188275881766
Testing on Congress 117 F1: 0.601917336933838

Training on Congress 116
Best score: 0.714
Refit Time: 31.227
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.05
	tfidf__ngram_range: (1, 2)
Testing on Congress 115 F1: 0.7408989977276476
Testing on Congress 117 F1: 0.7200639105208106

Training on Congress 117
Best score: 0.711
Refit Time: 34.083
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.05
	tfidf__ngram_range: (1, 2)
Testing on Congress 115 F1: 0.674418393892329
Testing on Congress 116 F1: 0.7405934743144291

Mean fit time: 32.46 ± 1.20s
</code></pre>
<h4 id="results-summary-1">Results Summary</h4>
<p>Logistic regression improves upon Naive Bayes performance:</p>
<ul>
<li><strong>Higher F1 scores</strong>: Generally 5-7 percentage points better than Naive Bayes</li>
<li><strong>Consistent hyperparameters</strong>: Optimal settings remain stable across Congresses</li>
<li><strong>Reasonable training time</strong>: 30-35 seconds per model remains manageable</li>
<li><strong>Strong cross-Congress generalization</strong>: F1 scores consistently above 0.70</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/logreg_title_policy_area/f1s.webp"
         alt="Logistic Regression Policy Area Classification F1 Score"
         title="Logistic Regression Policy Area Classification F1 Score"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Logistic Regression Policy Area Classification F1 Score</figcaption>
    
</figure>

<h4 id="xgboost-1">XGBoost</h4>
<p>Title-only XGBoost experiments are run with the following settings:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>sweep_xgb(
</span></span><span style="display:flex;"><span>    data,
</span></span><span style="display:flex;"><span>    X_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;title&#39;</span>,
</span></span><span style="display:flex;"><span>    y_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;policy_area&#39;</span>,
</span></span><span style="display:flex;"><span>    tfidf_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>,),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    xgb_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_depth&#39;</span>: (<span style="color:#ae81ff">6</span>,),
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;eta&#39;</span>: (<span style="color:#ae81ff">0.3</span>,),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>and the results:</p>
<pre><code>Training on Congress 115
Best score: 0.591
Refit Time: 198.063
Best parameters set:
	clf__eta: 0.3
	clf__max_depth: 6
	clf__num_class: 33
	tfidf__max_df: 0.05
Testing on Congress 116 F1: 0.5649530686141018
Testing on Congress 117 F1: 0.5215939580735101

Training on Congress 116
Best score: 0.600
Refit Time: 264.824
Best parameters set:
	clf__eta: 0.3
	clf__max_depth: 6
	clf__num_class: 33
	tfidf__max_df: 0.05
Testing on Congress 115 F1: 0.6037922738570368
Testing on Congress 117 F1: 0.5965027418245722

Training on Congress 117
Best score: 0.595
Refit Time: 249.799
Best parameters set:
	clf__eta: 0.3
	clf__max_depth: 6
	clf__num_class: 33
	tfidf__max_df: 0.05
Testing on Congress 115 F1: 0.5600491477899472
Testing on Congress 116 F1: 0.60815381664894

Mean fit time: 237.56 ± 28.60s
</code></pre>
<h4 id="results-summary-2">Results Summary</h4>
<p>XGBoost underperforms relative to expectations:</p>
<ul>
<li><strong>Poor performance</strong>: F1 scores significantly below linear models (0.55-0.60 range)</li>
<li><strong>Long training times</strong>: 4+ minutes per model with limited hyperparameter exploration</li>
<li><strong>Questionable value</strong>: The computational cost doesn&rsquo;t justify the poor performance</li>
</ul>
<p>Given these results, we focus on the more promising linear models for subsequent experiments with longer text inputs.</p>















<figure class="post-figure center ">
    <img src="/img/xgb_title_policy_area/f1s.webp"
         alt="XGBoost Policy Area Classification F1 Score"
         title="XGBoost Policy Area Classification F1 Score"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">XGBoost Policy Area Classification F1 Score</figcaption>
    
</figure>

<h4 id="training-efficiency">Training Efficiency</h4>
<p>The computational costs vary dramatically:</p>
<table>
	<thead>
			<tr>
					<th>Model</th>
					<th>Training Time</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Naive Bayes</td>
					<td>0.54 $\pm$ 0.03s</td>
			</tr>
			<tr>
					<td>Logistic Regression</td>
					<td>32.46 $\pm$ 1.20s</td>
			</tr>
			<tr>
					<td>XGBoost</td>
					<td>237.56 $\pm$ 28.60s</td>
			</tr>
	</tbody>
</table>
<p>XGBoost&rsquo;s poor performance despite high computational cost suggests that tree-based methods may not be well-suited for sparse TF-IDF features. This is a classic example of the &ldquo;curse of dimensionality&rdquo;: tree-based models struggle to make effective splits in highly sparse, high-dimensional bag-of-words spaces compared to linear models that simply assign weights to all features simultaneously. We&rsquo;ll focus on linear models for the remaining experiments.</p>
<h3 id="summary-only-results">Summary-Only Results</h3>
<p>Using bill summaries provides substantially more context than titles alone, leading to significant performance improvements.</p>
<h4 id="naive-bayes-performance">Naive Bayes Performance</h4>
<p>The summary-based models show dramatic improvement over title-only versions:</p>
<ul>
<li><strong>F1 scores</strong>: 0.85+ within-Congress, 0.77-0.86 cross-Congress</li>
<li><strong>Training time</strong>: Still fast at ~3.4 seconds</li>
<li><strong>Strong generalization</strong>: Consistent performance across time periods</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/nb_summary_policy_area/f1s.webp"
         alt="Naive Bayes Summary Performance"
         title="Naive Bayes Summary Performance"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Summary-based models achieve 80%+ F1 scores across most Congress combinations</figcaption>
    
</figure>

<h4 id="logistic-regression-performance">Logistic Regression Performance</h4>
<p>Logistic regression slightly outperforms Naive Bayes on summaries:</p>
<ul>
<li><strong>F1 scores</strong>: 0.86+ within-Congress, 0.79-0.87 cross-Congress</li>
<li><strong>Training time</strong>: Reasonable at ~12 seconds</li>
<li><strong>Stable hyperparameters</strong>: Consistent optimal settings across Congresses</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/logreg_summary_policy_area/f1s.webp"
         alt="Logistic Regression Summary Performance"
         title="Logistic Regression Summary Performance"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Logistic regression maintains slight performance advantage over Naive Bayes</figcaption>
    
</figure>

<p>The performance difference between models suggests they rely on similar feature patterns, with logistic regression better capturing feature interactions.</p>
<h4 id="logistic-regression-2">Logistic Regression</h4>
<p>Summary-only Logistic Regression experiments are run with the following settings:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>sweep_logreg(
</span></span><span style="display:flex;"><span>    data,
</span></span><span style="display:flex;"><span>    X_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;summary&#39;</span>,
</span></span><span style="display:flex;"><span>    y_key<span style="color:#f92672">=</span><span style="color:#e6db74">&#39;policy_area&#39;</span>,
</span></span><span style="display:flex;"><span>    tfidf_params<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;lowercase&#39;</span>: <span style="color:#66d9ef">True</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;dtype&#39;</span>: np<span style="color:#f92672">.</span>float32,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    tfidf_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#75715e"># &#39;ngram_range&#39;: [(1, 1), (1, 2)],</span>
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_df&#39;</span>: (<span style="color:#ae81ff">0.05</span>, <span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">0.25</span>),
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    logreg_params<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;max_iter&#39;</span>: <span style="color:#ae81ff">1000</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;random_state&#39;</span>: <span style="color:#ae81ff">42</span>,
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;class_weight&#39;</span>: <span style="color:#e6db74">&#39;balanced&#39;</span>,
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>    logreg_grid<span style="color:#f92672">=</span>{
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;C&#39;</span>: [<span style="color:#ae81ff">0.1</span>, <span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">10</span>],
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>And the results:</p>
<pre><code>Training on Congress 115
Best score: 0.862
Refit Time: 9.007
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.25
Testing on Congress 116 F1: 0.8284864693401133
Testing on Congress 117 F1: 0.7934161507811646

Training on Congress 116
Best score: 0.865
Refit Time: 13.897
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.25
Testing on Congress 115 F1: 0.8637852557418315
Testing on Congress 117 F1: 0.8594775615031977

Training on Congress 117
Best score: 0.862
Refit Time: 12.167
Best parameters set:
	clf__C: 10
	tfidf__max_df: 0.25
Testing on Congress 115 F1: 0.8355736563084967
Testing on Congress 116 F1: 0.8696403838390832

Mean fit time: 11.69 ± 2.02s
</code></pre>















<figure class="post-figure center ">
    <img src="/img/logreg_summary_policy_area/f1s.webp"
         alt="Logistic Regression Policy Area Classification F1 Score"
         title="Logistic Regression Policy Area Classification F1 Score"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Logistic Regression Policy Area Classification F1 Score</figcaption>
    
</figure>

<h3 id="full-text-results">Full Text Results</h3>
<p>We test whether complete bill text improves performance over summaries, using optimal hyperparameters from summary experiments.</p>
<h4 id="naive-bayes-on-full-text">Naive Bayes on Full Text</h4>
<p>Surprisingly, full text yields slightly lower performance than summaries:</p>
<ul>
<li><strong>F1 scores</strong>: 0.84-0.85 within-Congress, 0.77-0.86 cross-Congress</li>
<li><strong>Training time</strong>: ~50 seconds (10x slower than summaries)</li>
<li><strong>Performance drop</strong>: Likely due to increased noise in lengthy documents</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/nb_text_policy_area/f1s.webp"
         alt="Naive Bayes Full Text Performance"
         title="Naive Bayes Full Text Performance"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Full text performance is slightly worse than summaries, suggesting diminishing returns</figcaption>
    
</figure>

<h4 id="logistic-regression-on-full-text">Logistic Regression on Full Text</h4>
<p>Logistic regression shows the strongest performance on full text:</p>
<ul>
<li><strong>F1 scores</strong>: 0.87-0.88 within-Congress, 0.83-0.89 cross-Congress</li>
<li><strong>Training time</strong>: ~70 seconds</li>
<li><strong>Best overall performance</strong>: up to 0.89 F1 on the strongest single cross-Congress pair (best within-Congress score 0.877)</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/logreg_text_policy_area/f1s.webp"
         alt="Logistic Regression Full Text Performance"
         title="Logistic Regression Full Text Performance"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">Logistic regression achieves the best performance using full bill text</figcaption>
    
</figure>

<p>The logistic regression model benefits from having access to complete legislative language while effectively regularizing against noise.</p>
<h2 id="key-findings">Key Findings</h2>
<p>This baseline study establishes several important results:</p>
<p><strong>Best performing model</strong>: Logistic regression trained on full bill text reaches up to 0.89 F1 on the strongest single cross-Congress pair (best within-Congress score 0.877), providing a strong benchmark for future deep learning approaches.</p>
<p><strong>Text input comparison</strong>:</p>
<ul>
<li>Titles: Limited but fast (F1 ~0.65-0.70)</li>
<li>Summaries: Good balance of performance and efficiency (F1 ~0.85)</li>
<li>Full text: Best performance but computationally expensive (certified weighted-F1 0.871-0.877; up to ~0.89 on the strongest single cross-Congress pair)</li>
</ul>
<p><strong>Cross-Congress generalization</strong>: Models trained on one Congress generalize reasonably well to others, though performance decreases with temporal distance between sessions.</p>
<p><strong>Model performance ranking</strong>: Logistic Regression &gt; Naive Bayes &raquo; XGBoost for this text classification task.</p>
<h2 id="next-steps">Next Steps</h2>
<p>The strong baseline performance sets the stage for several research directions:</p>
<ol>
<li><strong>Deep learning models</strong>: Transformer-based approaches using pre-trained language models</li>
<li><strong>Dataset expansion</strong>: Including additional Congresses and more detailed bill metadata</li>
<li><strong>Error analysis</strong>: Understanding failure cases and class-specific performance patterns</li>
<li><strong>Feature engineering</strong>: Exploring domain-specific text preprocessing and feature extraction</li>
</ol>
<p>The complete dataset and experimental code are available for researchers interested in building upon these baselines.</p>
<p><strong>Resources</strong>:</p>
<ul>
<li>Dataset: <a href="https://huggingface.co/datasets/hheiden/us-congress-bill-policy-115_117">Hugging Face: hheiden/us-congress-bill-policy-115_117</a></li>
<li>Leaderboard: <a href="/leaderboards/policy_area_classification_leaderboard/">Policy Area Classification Leaderboard</a></li>
<li>Project: <a href="/projects/congressional-data-analysis/">Congressional Knowledge Graph &amp; Policy Classification</a></li>
</ul>
]]></content:encoded></item><item><title>How Does Congress Actually Work? Data from 15K Bills</title><link>https://hunterheidenreich.com/posts/us-117th-congress-data-exploration/</link><pubDate>Thu, 05 Oct 2023 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/posts/us-117th-congress-data-exploration/</guid><description>What happens to bills in Congress? Analyzing 15K+ bills from the 117th Congress to understand legislative patterns, party dynamics, and success rates.</description><content:encoded><![CDATA[<h2 id="introduction">Introduction</h2>
<p>Analyzing congressional data reveals the underlying mechanics of the legislative process. Legislative text is a large, structured corpus well suited to text classification and other NLP tasks. I scraped data from Congress.gov to analyze what actually happens to the thousands of bills introduced each session and to build a foundational dataset for downstream machine learning tasks.</p>
<p>This analysis focuses on the 117th Congress (2021-2023), examining 15,000+ bills to understand basic patterns: Which bills get introduced? How many receive votes? What factors influence success?</p>
<p>This post covers the foundational exploratory analysis and data collection process, setting the stage for <a href="/posts/congressional-bill-policy-area-classification/">predictive modeling and policy area classification</a>.</p>
<h2 id="data-collection">Data Collection</h2>
<p>My primary source is <a href="https://www.congress.gov/">Congress.gov</a>, maintained by the Library of Congress. I focused on the 117th Congress (2021-2023), collecting data on bills and joint resolutions, omitting simple resolutions, concurrent resolutions, and amendments.</p>
<p><strong>Data collected:</strong></p>
<table>
	<thead>
			<tr>
					<th>Bill Type</th>
					<th>Introduced</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>House Bill</td>
					<td>9,698</td>
			</tr>
			<tr>
					<td>House Joint Resolution</td>
					<td>106</td>
			</tr>
			<tr>
					<td>Senate Bill</td>
					<td>5,357</td>
			</tr>
			<tr>
					<td>Senate Joint Resolution</td>
					<td>70</td>
			</tr>
			<tr>
					<td><strong>Total</strong></td>
					<td><strong>15,231</strong></td>
			</tr>
	</tbody>
</table>
<h3 id="technical-implementation">Technical Implementation</h3>
<p>Building a usable NLP dataset requires careful handling of the source. Congress.gov loads content dynamically and presents nested DOM structures, so the scraper combines static HTML parsing with a headless browser to render JavaScript before parsing.</p>
<p><strong>Implementation details:</strong></p>
<ul>
<li><a href="https://www.python.org/">Python</a> for core orchestration and data schema management</li>
<li><a href="https://www.selenium.dev/">Selenium</a> for executing JavaScript and loading dynamic page elements</li>
<li><a href="https://www.crummy.com/software/BeautifulSoup/bs4/doc/">BeautifulSoup</a> for structured HTML parsing</li>
<li>Regex for text normalization and extracting clean legislative text for language models</li>
</ul>
<p>The crawler used 5-second delays between requests to respect server limits, a roughly 3-day collection run. It handles edge cases in congressional text formatting and writes one JSON record per bill on a fixed schema. The crawler and processed data are available on <a href="https://github.com/hunter-heidenreich/congress-scraper">GitHub</a>.</p>
<p>For each bill, I queried two pages:</p>
<ul>
<li>All info page: <code>https://www.congress.gov/bill/117th-congress/{bill_type}/{bill_id}/all-info</code></li>
<li>Text page: <code>https://www.congress.gov/bill/117th-congress/{bill_type}/{bill_id}/text?format=txt</code></li>
</ul>
<p>The parsing process involved targeting specific HTML elements and implementing basic caching to avoid redundant requests.</p>
<h2 id="key-findings">Key Findings</h2>
<p>The analysis reveals clear patterns in congressional activity. Most bills never receive votes, and success rates vary significantly by party and policy area.</p>
<h3 id="legislative-outcomes">Legislative Outcomes</h3>
<p>The fundamental question: what happens to bills after introduction?</p>
<p>Each bill has a tracker status indicating its position in the legislative process. The eight possible statuses can be grouped into three meaningful categories:</p>
<ul>
<li><strong>Introduced</strong>: Bills introduced but never voted on</li>
<li><strong>Stalled</strong>: Bills that saw votes but didn&rsquo;t become law (since the 117th Congress ended, these effectively died)</li>
<li><strong>Law</strong>: Bills signed by the President</li>
</ul>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Introduced</th>
					<th>Stalled</th>
					<th>Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>House Bill</td>
					<td>8,977</td>
					<td>523</td>
					<td>198</td>
			</tr>
			<tr>
					<td>House Joint Resolution</td>
					<td>102</td>
					<td>1</td>
					<td>3</td>
			</tr>
			<tr>
					<td>Senate Bill</td>
					<td>5,083</td>
					<td>114</td>
					<td>160</td>
			</tr>
			<tr>
					<td>Senate Joint Resolution</td>
					<td>57</td>
					<td>9</td>
					<td>4</td>
			</tr>
			<tr>
					<td><strong>Total</strong></td>
					<td><strong>14,219</strong></td>
					<td><strong>647</strong></td>
					<td><strong>365</strong></td>
			</tr>
	</tbody>
</table>
<p><strong>Key insights:</strong></p>
<ul>
<li>Only 7% of introduced bills ever receive a vote</li>
<li>Of bills that receive votes, 36% become law</li>
<li>Overall, just 2% of introduced bills become law</li>
</ul>
<h3 id="sponsor-analysis">Sponsor Analysis</h3>
<p>The bill sponsor (the primary member who introduces legislation) provides insights into party and geographic patterns.</p>
<h4 id="party-breakdown">Party Breakdown</h4>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Introduced</th>
					<th>Stalled</th>
					<th>Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Democrat</td>
					<td>8,271</td>
					<td>437</td>
					<td>235</td>
			</tr>
			<tr>
					<td>Republican</td>
					<td>5,883</td>
					<td>210</td>
					<td>130</td>
			</tr>
			<tr>
					<td>Independent</td>
					<td>65</td>
					<td>0</td>
					<td>0</td>
			</tr>
	</tbody>
</table>
<p><strong>Party comparison:</strong></p>
<ul>
<li><strong>Democrats</strong>: 7.5% of bills moved beyond introduction; 2.6% became law</li>
<li><strong>Republicans</strong>: 5.5% of bills moved beyond introduction; 2.1% became law</li>
<li>When bills do advance, Republicans have a slightly higher success rate (38% vs 35%)</li>
</ul>
<h4 id="geographic-distribution">Geographic Distribution</h4>
<p><strong>Top 10 states by bills introduced:</strong></p>
<table>
	<thead>
			<tr>
					<th>Ranking</th>
					<th>State: Introduced</th>
					<th>State: Stalled</th>
					<th>State: Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>CA: 1,350</td>
					<td>CA: 93</td>
					<td>CA: 34</td>
			</tr>
			<tr>
					<td>2</td>
					<td>TX: 879</td>
					<td>NY: 44</td>
					<td>MI: 30</td>
			</tr>
			<tr>
					<td>3</td>
					<td>NY: 784</td>
					<td>TX: 43</td>
					<td>TX: 25</td>
			</tr>
			<tr>
					<td>4</td>
					<td>FL: 766</td>
					<td>MI: 28</td>
					<td>NY: 24</td>
			</tr>
			<tr>
					<td>5</td>
					<td>IL: 660</td>
					<td>NJ: 28</td>
					<td>MN: 17</td>
			</tr>
			<tr>
					<td>6</td>
					<td>PA: 521</td>
					<td>IL: 27</td>
					<td>IL: 16</td>
			</tr>
			<tr>
					<td>7</td>
					<td>NJ: 478</td>
					<td>VA: 26</td>
					<td>OH: 11</td>
			</tr>
			<tr>
					<td>8</td>
					<td>MI: 380</td>
					<td>FL: 24</td>
					<td>VA: 11</td>
			</tr>
			<tr>
					<td>9</td>
					<td>OH: 377</td>
					<td>PA: 22</td>
					<td>FL: 11</td>
			</tr>
			<tr>
					<td>10</td>
					<td>MA: 361</td>
					<td>OH: 19</td>
					<td>GA: 9</td>
			</tr>
	</tbody>
</table>
<p><strong>Per-representative normalization reveals different patterns:</strong></p>
<table>
	<thead>
			<tr>
					<th>Ranking</th>
					<th>State: Introduced</th>
					<th>State: Stalled</th>
					<th>State: Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>DC: 101.0</td>
					<td>DC: 7.0</td>
					<td>AK: 2.2</td>
			</tr>
			<tr>
					<td>2</td>
					<td>NH: 47.5</td>
					<td>AK: 2.8</td>
					<td>NH: 2.0</td>
			</tr>
			<tr>
					<td>3</td>
					<td>MT: 44.0</td>
					<td>IA: 2.3</td>
					<td>MT: 2.0</td>
			</tr>
			<tr>
					<td>4</td>
					<td>OR: 41.0</td>
					<td>SD: 2.3</td>
					<td>MI: 1.9</td>
			</tr>
			<tr>
					<td>5</td>
					<td>NV: 40.0</td>
					<td>NH: 2.2</td>
					<td>MN: 1.5</td>
			</tr>
			<tr>
					<td>6</td>
					<td>DE: 38.7</td>
					<td>VA: 2.0</td>
					<td>HI: 1.5</td>
			</tr>
			<tr>
					<td>7</td>
					<td>SD: 38.3</td>
					<td>NJ: 2.0</td>
					<td>CT: 1.3</td>
			</tr>
			<tr>
					<td>8</td>
					<td>IA: 37.7</td>
					<td>PR: 2.0</td>
					<td>IA: 1.2</td>
			</tr>
			<tr>
					<td>9</td>
					<td>RI: 36.5</td>
					<td>NV: 1.8</td>
					<td>OR: 1.1</td>
			</tr>
			<tr>
					<td>10</td>
					<td>UT: 36.0</td>
					<td>MO: 1.8</td>
					<td>SD: 1.0</td>
			</tr>
	</tbody>
</table>
<h4 id="top-individual-sponsors">Top Individual Sponsors</h4>
<p><strong>Most prolific legislators by bills introduced:</strong></p>
<table>
	<thead>
			<tr>
					<th>Ranking</th>
					<th>Individual: Introduced</th>
					<th>Individual: Stalled</th>
					<th>Individual: Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>Sen. Rubio (R-FL): 186</td>
					<td>Sen. Peters (D-MI): 11</td>
					<td>Sen. Peters (D-MI): 19</td>
			</tr>
			<tr>
					<td>2</td>
					<td>Sen. Klobuchar (D-MN): 143</td>
					<td>Sen. Cornyn (R-TX): 8</td>
					<td>Sen. Cornyn (R-TX): 15</td>
			</tr>
			<tr>
					<td>3</td>
					<td>Sen. Lee (R-UT): 125</td>
					<td>Rep. Connolly (D-VA-11): 8</td>
					<td>Sen. Klobuchar (D-MN): 7</td>
			</tr>
			<tr>
					<td>4</td>
					<td>Sen. Markey (D-MA): 118</td>
					<td>Rep. Takano (D-CA-41): 8</td>
					<td>Sen. Tester (D-MT): 6</td>
			</tr>
			<tr>
					<td>5</td>
					<td>Sen. Casey (D-PA): 116</td>
					<td>Sen. Grassley (R-IA): 7</td>
					<td>Sen. Rubio (R-FL): 6</td>
			</tr>
			<tr>
					<td>6</td>
					<td>Sen. Cortez Masto (D-NV): 109</td>
					<td>Del. Norton (D-DC): 7</td>
					<td>Rep. DeLauro (D-CT-3): 6</td>
			</tr>
			<tr>
					<td>7</td>
					<td>Sen. Booker (D-NJ): 106</td>
					<td>Rep. Johnson (D-TX-30): 7</td>
					<td>Sen. Grassley (R-IA): 5</td>
			</tr>
			<tr>
					<td>8</td>
					<td>Sen. Durbin (D-IL): 102</td>
					<td>Rep. Katko (R-NY-24): 7</td>
					<td>Sen. Ossoff (D-GA): 4</td>
			</tr>
			<tr>
					<td>9</td>
					<td>Del. Norton (D-DC): 101</td>
					<td>Rep. Dean (D-PA-4): 6</td>
					<td>Sen. Murkowski (R-AK): 4</td>
			</tr>
			<tr>
					<td>10</td>
					<td>Sen. Menendez (D-NJ): 99</td>
					<td>Rep. Wagner (R-MO-2): 6</td>
					<td>Sen. Padilla (D-CA): 4</td>
			</tr>
	</tbody>
</table>
<p><strong>Effectiveness score (laws enacted / total bills):</strong></p>
<p>$$
\text{effectiveness} = \frac{\text{bills that became law}}{\text{total bills introduced}}
$$</p>
<table>
	<thead>
			<tr>
					<th>Ranking</th>
					<th>Individual: Effectiveness Score</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>Rep. Pelosi (D-CA-12): 0.500</td>
			</tr>
			<tr>
					<td>2</td>
					<td>Rep. Mrvan (D-IN-1): 0.444</td>
			</tr>
			<tr>
					<td>3</td>
					<td>Rep. Yarmuth (D-KY-3): 0.333</td>
			</tr>
			<tr>
					<td>4</td>
					<td>Rep. Stivers (R-OH-15): 0.250</td>
			</tr>
			<tr>
					<td>5</td>
					<td>Rep. Graves (R-MO-6): 0.222</td>
			</tr>
			<tr>
					<td>6</td>
					<td>Rep. Jeffries (D-NY-8): 0.200</td>
			</tr>
			<tr>
					<td>7</td>
					<td>Rep. Neal (D-MA-1): 0.200</td>
			</tr>
			<tr>
					<td>8</td>
					<td>Rep. Palazzo (R-MS-4): 0.200</td>
			</tr>
			<tr>
					<td>9</td>
					<td>Sen. Peters (D-MI): 0.186</td>
			</tr>
			<tr>
					<td>10</td>
					<td>Rep. Fischbach (R-MN-7): 0.176</td>
			</tr>
	</tbody>
</table>
<h3 id="policy-focus-areas">Policy Focus Areas</h3>
<p>Each bill is assigned a primary policy area. Here are the most active areas by legislative outcome:</p>
<table>
	<thead>
			<tr>
					<th>Ranking</th>
					<th>Policy Area: Introduced</th>
					<th>Policy Area: Stalled</th>
					<th>Policy Area: Law</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>Health: 1,885</td>
					<td>Government Operations: 79</td>
					<td>Government Operations: 94</td>
			</tr>
			<tr>
					<td>2</td>
					<td>Armed Forces: 1,114</td>
					<td>Armed Forces: 60</td>
					<td>Armed Forces: 69</td>
			</tr>
			<tr>
					<td>3</td>
					<td>Taxation: 1,066</td>
					<td>International Affairs: 60</td>
					<td>Crime &amp; Law Enforcement: 31</td>
			</tr>
			<tr>
					<td>4</td>
					<td>Government Operations: 982</td>
					<td>Health: 56</td>
					<td>Health: 19</td>
			</tr>
			<tr>
					<td>5</td>
					<td>International Affairs: 866</td>
					<td>Crime &amp; Law Enforcement: 44</td>
					<td>Native Americans: 17</td>
			</tr>
			<tr>
					<td>6</td>
					<td>Crime &amp; Law Enforcement: 842</td>
					<td>Public Lands: 44</td>
					<td>International Affairs: 14</td>
			</tr>
			<tr>
					<td>7</td>
					<td>Education: 663</td>
					<td>Science &amp; Technology: 44</td>
					<td>Economics &amp; Finance: 13</td>
			</tr>
			<tr>
					<td>8</td>
					<td>Transportation: 663</td>
					<td>Commerce: 43</td>
					<td>Public Lands: 13</td>
			</tr>
			<tr>
					<td>9</td>
					<td>Public Lands: 548</td>
					<td>Finance: 34</td>
					<td>Commerce: 13</td>
			</tr>
			<tr>
					<td>10</td>
					<td>Finance: 547</td>
					<td>Emergency Management: 27</td>
					<td>Emergency Management: 11</td>
			</tr>
	</tbody>
</table>
<p>Notable patterns: Health dominates introductions but has lower success rates, while government operations and armed forces bills are more likely to become law.</p>
<h2 id="next-steps">Next Steps</h2>
<p>This analysis establishes baseline patterns: most bills fail, party affiliation affects success rates, and certain policy areas perform better than others.</p>
<p>Future work could explore:</p>
<ul>
<li>Committee dynamics and voting patterns</li>
<li>Geographic analysis of state-level interests</li>
<li>Bill text analysis using NLP techniques</li>
<li>Predictive modeling for bill outcomes</li>
</ul>
<blockquote>
<p><strong>Update</strong>: I&rsquo;ve since applied machine learning to this type of data in <a href="/posts/congressional-bill-policy-area-classification/">Congressional Bill Policy Area Classification</a>, using 48K+ bills from three Congresses to automatically categorize bills by policy area.</p>
</blockquote>
<p>The complete dataset and code are publicly available to support further research into legislative transparency.</p>
]]></content:encoded></item><item><title>Congressional Knowledge Graph &amp; Policy Classification</title><link>https://hunterheidenreich.com/projects/congressional-data-analysis/</link><pubDate>Wed, 01 Mar 2023 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/projects/congressional-data-analysis/</guid><description>A 47,000+ bill knowledge graph from Congress.gov with co-sponsorship networks and TF-IDF baselines for 33-class policy-area classification.</description><content:encoded><![CDATA[<h2 id="overview">Overview</h2>
<p>A computational social science project that constructed a dataset of 47,000+ US congressional bills by extracting legislative text and metadata from the 115th-117th Congresses. The project creates a &ldquo;legislative graph&rdquo;
(linking sponsors, committees, and bill text) and establishes TF-IDF baseline models for policy area classification across 33 (highly imbalanced) policy classes, now hosted on Hugging Face to support reproducible political science research.</p>
<h2 id="features">Features</h2>
<h3 id="intelligent-data-acquisition">Intelligent Data Acquisition</h3>
<p>Standard APIs impose strict rate limits. I built a Selenium-based extraction engine to handle Congress.gov&rsquo;s complex DOM structures.</p>
<ul>
<li><strong>Optimization</strong>: Targeted aggregate endpoints (e.g., <code>/all-info</code>) to pull each bill&rsquo;s text and metadata in fewer requests.</li>
<li><strong>Resilience</strong>: Implemented a local caching layer to store raw HTML, separating the fetch step from the parse step. This made the parse step re-runnable without re-fetching, and minimized server load during iterative development.</li>
<li><strong>Graph construction</strong>: Beyond simple text, the script extracts relational data including co-sponsorship networks, committee assignments, and related bill lineage.</li>
</ul>
<h3 id="natural-language-processing">Natural Language Processing</h3>
<ul>
<li><strong>Corpus construction</strong>: Cleaned and normalized legislative text, removing procedural artifacts (e.g., &ldquo;A BILL TO&hellip;&rdquo;) to isolate semantic policy content.</li>
<li><strong>Feature engineering</strong>: Utilized TF-IDF vectorization with N-gram analysis to capture legislative jargon.</li>
<li><strong>Modeling</strong>: Benchmarked Naive Bayes, Logistic Regression, and gradient-boosted trees (XGBoost), reaching ~0.86 weighted F1 on bill summaries and ~0.88 on full text within-Congress (0.877; up to 0.89 on the strongest cross-Congress pair). Weighted F1, not raw accuracy, is the honest metric here: the 33 policy classes are severely imbalanced (Health has 5,911 bills; Social Sciences and History has 15).</li>
</ul>
<h2 id="usage">Usage</h2>
<p>The dataset is available on Hugging Face and can be loaded directly via the <code>datasets</code> library. The scraper can be run locally to fetch new bills.</p>
<h2 id="results">Results</h2>
<ul>
<li><strong>Temporal drift</strong>: Policy priorities and terminology showed measurable shifts across congressional sessions (115th vs 117th).</li>
<li><strong>Classification success</strong>: Simple linear models (Logistic Regression and Naive Bayes) proved effective at distinguishing policy domains, outperforming gradient-boosted trees on these sparse TF-IDF features and suggesting legislative language is highly structured.</li>
</ul>
<h2 id="impact--deliverables">Impact &amp; Deliverables</h2>
<ul>
<li><strong>Hugging Face dataset</strong>: Released a machine-readable, ML-ready dataset of modern bills (115th-117th Congresses) on Hugging Face for reproducible research.</li>
<li><strong>Open source tooling</strong>: Published the scraper and parsing logic to allow others to extend the dataset to future congresses.</li>
<li><strong>Academic benchmark</strong>: Establishing a clear baseline for &ldquo;Government NLP&rdquo; tasks, aiding in the automated transparency and monitoring of new legislation.</li>
</ul>
<h2 id="related-work">Related Work</h2>
<ul>
<li><a href="/posts/us-117th-congress-data-exploration/">117th Congress Data Exploration</a></li>
<li><a href="/posts/congressional-bill-policy-area-classification/">Congressional Bill Policy Area Classification</a></li>
</ul>
]]></content:encoded></item><item><title>Look, Don't Tweet: Unified Data Models for Social NLP</title><link>https://hunterheidenreich.com/research/look-dont-tweet/</link><pubDate>Wed, 30 Jun 2021 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/research/look-dont-tweet/</guid><description>PyConversations library and unified data schema for normalizing 300M+ posts across Twitter, Reddit, Facebook, and 4chan.</description><content:encoded><![CDATA[<h2 id="abstract">Abstract</h2>
<p>Social media research is often siloed by platform, with tools built specifically for Twitter&rsquo;s flat structure or Reddit&rsquo;s tree structure. This fragmentation makes cross-platform analysis difficult. In this work, I introduce <strong><a href="https://github.com/hunter-heidenreich/pyconversations">PyConversations</a></strong>, an open-source Python package that normalizes data from Twitter, Facebook, Reddit, and 4chan into a single, platform-agnostic data model. <em>(Note: the repository is archived and no longer actively maintained.)</em></p>
<p>Leveraging this tool, I processed over <strong>308 million posts</strong> to analyze the structural &ldquo;shape&rdquo; of online conversations. I then evaluated the efficacy of domain-adaptive pre-training (DAPT) for Transformer-based language models, finding that training on a toxic domain (4chan) boosts hate-speech detection by over 5 F1.</p>
<h2 id="the-engineering-problem-data-normalization">The Engineering Problem: Data Normalization</h2>
<p>Social media platforms impose different structural constraints on discourse, making it difficult to feed heterogeneous data into a single ML pipeline:</p>
<ul>
<li><strong>Twitter:</strong> Technically allows infinite depth, but functionally operates as a flat stream or shallow tree.</li>
<li><strong>Facebook:</strong> Enforces a hard limit of two depth levels (comments and replies), resulting in &ldquo;short and fat&rdquo; conversation trees.</li>
<li><strong>Reddit &amp; 4chan:</strong> Allow for deep, branching tree structures.</li>
</ul>
<p>To solve this, I designed a <strong>Universal Message Schema</strong> and the <strong>PyConversations</strong> library. This system ingests raw dumps from these disparate sources and maps them to a unified Directed Acyclic Graph (DAG) format, preserving the parent-child relationships regardless of the source platform&rsquo;s constraints.</p>
<h2 id="key-contributions">Key Contributions</h2>
<ul>
<li><strong>PyConversations Library</strong>: An open-source package for robust conversational analysis, featuring graph-based traversing and filtering.</li>
<li><strong>Massive Dataset Analysis</strong>: Processed a collection of <strong>308 million posts</strong> and <strong>15.8 million conversations</strong>, creating one of the largest comparative cross-platform analyses at the time of thesis submission.</li>
<li><strong>Structural Insights</strong>: Quantified how UI constraints shape human behavior. For instance, Facebook&rsquo;s depth limit forces users to &ldquo;bunch&rdquo; comments, creating uniquely wide conversation trees compared to Reddit&rsquo;s deep, narrow threads.</li>
<li><strong>Domain Adaptation Experiments</strong>: Continued-pretrained RoBERTa on platform-specific slices (e.g., the 4chan-adapted <code>RoBERTa-4chan</code>), demonstrating that exposing models to toxic domains improved hate-speech detection F1 by over 5 points.</li>
</ul>
<h2 id="structural-analysis-findings">Structural Analysis Findings</h2>
<p>By treating conversations as graphs, I uncovered distinct topological signatures for each platform:</p>
<h3 id="the-shape-of-discourse">The &ldquo;Shape&rdquo; of Discourse</h3>
<p>I measured the <strong>width</strong> (max posts at any depth) and <strong>depth</strong> (max distance from root) of conversation trees.</p>
<ul>
<li><strong>Facebook</strong> exhibited a &ldquo;short and fat&rdquo; topology due to its 2-level nesting limit.</li>
<li><strong>4chan</strong> threads were surprisingly shallow despite having no depth limits. This suggests that the platform&rsquo;s <strong>ephemerality</strong> (threads are deleted quickly) and the &ldquo;bump limit&rdquo; mechanic discourage long-term dialogue, though data scraping limitations on this transient platform also contribute to this topology.</li>
<li><strong>Reddit</strong> maintained the most robust tree structures, with &ldquo;good faith&rdquo; communities like <em>r/ChangeMyView</em> showing distinct patterns of sustained engagement.</li>
</ul>
<h3 id="information-density">Information Density</h3>
<p>I analyzed <strong>Innovation Rate</strong>, a measure of how quickly a text introduces new vocabulary. Twitter and Facebook posts regress to negative rates; Reddit and 4chan posts sit well above zero.</p>
<h2 id="representation-learning--domain-adaptation">Representation Learning &amp; Domain Adaptation</h2>
<p>I experimented with &ldquo;Warm-Start&rdquo; tuning: taking a standard RoBERTa model and pre-training it further on platform-specific data before fine-tuning on downstream tasks (TweetEval).</p>
<ul>
<li><strong>Limited gains on most general tasks:</strong> Domain-adaptive pre-training added little on sentiment and emotion (from well under 1 up to a few F1 points), with irony detection the exception (+5.6 to +5.9 F1). Base RoBERTa already covers most of the signal for general NLP tasks.</li>
<li><strong>The Toxic Exception:</strong> The notable exception was <strong>Hate Speech Detection</strong>. The 4chan-adapted model (<code>RoBERTa-4chan</code>) was the strongest here, outperforming the baseline by over 5 F1. This highlights that for specialized, out-of-distribution language (like toxic slang), domain adaptation remains valuable.</li>
</ul>
<h2 id="citation">Citation</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bibtex" data-lang="bibtex"><span style="display:flex;"><span><span style="color:#a6e22e">@thesis</span>{heidenreich2021look,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">title</span>=<span style="color:#e6db74">{Look, Don&#39;t Tweet: Representation Learning and Social Media}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">author</span>=<span style="color:#e6db74">{Hunter Heidenreich}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">year</span>=<span style="color:#e6db74">{2021}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">school</span>=<span style="color:#e6db74">{Drexel University}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">type</span>=<span style="color:#e6db74">{Undergraduate Senior Thesis}</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="related-work">Related Work</h2>
<p>For related work on how social media content surfaces in digital journalism, including a dataset of embedded tweets across 273,899 news articles, see <a href="/research/newstweet-social-media-journalism/">NewsTweet Dataset: Social Media in Digital Journalism</a>.</p>
]]></content:encoded></item><item><title>NewsTweet Dataset: Social Media in Digital Journalism</title><link>https://hunterheidenreich.com/research/newstweet-social-media-journalism/</link><pubDate>Sat, 01 Aug 2020 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/research/newstweet-social-media-journalism/</guid><description>NewsTweet dataset for studying embedded tweets in online journalism. Analysis shows 13% of Google News stories contain tweets.</description><content:encoded><![CDATA[<h2 id="abstract">Abstract</h2>
<p>We introduce NewsTweet, a dataset and data collection pipeline designed to study the embedding of social media in digital journalism. Our descriptive analysis of articles collected from Google News (chosen for its significant role in shaping attention) reveals that 13% of stories include embedded tweets. The dataset provides a foundation for exploring how social media content is sourced and which users become newsworthy. <em>(Note: this is an arXiv preprint from 2020 and was not published at a peer-reviewed venue.)</em></p>
<h2 id="key-contributions">Key Contributions</h2>
<ul>
<li><strong>Large-Scale Dataset</strong>: A dataset of 273,899 news articles, with 35,218 containing embedded tweets, collected from Google News RSS feeds over a four-month period.</li>
<li><strong>Data Collection Pipeline</strong>: Details an automated pipeline for acquiring news articles, extracting embedded tweets, and collecting the corresponding user timelines from Twitter&rsquo;s API.</li>
<li><strong>Descriptive Statistics</strong>: Presents statistics on the prevalence of tweet embedding across different news categories, outlets, and users, highlighting key patterns.</li>
</ul>
<h2 id="data-availability">Data Availability</h2>
<p>The NewsTweet dataset is not publicly available for direct download. Due to Twitter/X&rsquo;s Terms of Service restrictions on redistributing tweet content, the full dataset cannot be shared openly. Researchers interested in accessing the data or the collection pipeline are encouraged to contact the authors via the <a href="https://arxiv.org/abs/2008.02870">arXiv paper (arXiv:2008.02870)</a>.</p>
<h2 id="dataset-characteristics">Dataset Characteristics</h2>
<h3 id="scale-and-coverage">Scale and Coverage</h3>
<ul>
<li><strong>News Sources</strong>: 5,961 unique news domains aggregated through Google News RSS feeds.</li>
<li><strong>Time Period</strong>: Data collection initiated on May 15th, 2019, with the paper describing the first four months of data.</li>
<li><strong>Collection Velocity</strong>: The pipeline averaged <strong>2,302 articles per day</strong>, with approximately 296 containing embedded tweets.</li>
<li><strong>Content Types</strong>: Focuses specifically on embedded tweets from Twitter, the most frequently embedded platform.</li>
<li><strong>Metadata</strong>: Includes article source, Google News category (e.g., Sports, Health), and full tweet and user objects from the Twitter API.</li>
</ul>
<h3 id="technical-implementation">Technical Implementation</h3>
<ul>
<li><strong>RSS-to-API Pipeline</strong>: Automatically crawls Google News RSS feeds to extract article HTML, identifying embedded tweet IDs to fetch full objects via the Twitter API.</li>
<li><strong>Artifact Filtering</strong>: Implements cleaning protocols to handle artifacts, such as detecting and excluding YouTube pages that appear as articles in Google News feeds.</li>
<li><strong>Longitudinal Tracking</strong>: Features a &ldquo;top-off&rdquo; mechanism that continuously tracks discovered users, updating their timelines to capture historical context.</li>
<li><strong>Rate Limit Management</strong>: Utilizes a random sampling queue to maintain continuous data collection across thousands of users without exceeding Twitter API limits.</li>
</ul>
<h2 id="key-findings">Key Findings</h2>
<h3 id="embedding-prevalence">Embedding Prevalence</h3>
<ul>
<li><strong>13% of news articles</strong> in our Google News-sourced collection contained embedded tweets.</li>
<li><strong>Significant variation across categories</strong>: Sports (24% of articles) and Entertainment (14%) had the highest rates of embedding, while Health (2%) had the lowest.</li>
<li>News outlets that publish the most articles are well-known mass media organizations, while outlets with the highest average number of embeds per article are often focused on Sports and Entertainment.</li>
</ul>
<h3 id="user-and-content-patterns">User and Content Patterns</h3>
<ul>
<li><strong>Public figures dominate</strong>: Well-known figures like politicians and celebrities, alongside organizations, are embedded far more often than ordinary users.</li>
<li>Some users have a small number of their tweets embedded many times, while others gain newsworthiness from a wider range of their content.</li>
<li>The Health category, despite having few embedded tweets, had the highest proportion of unique tweets (93%), suggesting that when tweets are embedded, they are less likely to be reused across multiple stories.</li>
<li><strong>&ldquo;Catch-up&rdquo; Phenomenon</strong>: Data reveals a class of users with high &ldquo;embedding effectiveness&rdquo;: those embedded more frequently than they tweet. This suggests journalists often use embeddings to &ldquo;catch readers up&rdquo; on backstories for previously unknown individuals.</li>
</ul>
<h2 id="significance">Significance</h2>
<p>The dataset is a foundation for studying how social media surfaces in journalism: how sourcing routines are evolving in the digital age, how traditional outlets and social platforms interact, and how previously-unknown users become newsworthy, grounded in the per-category and per-outlet embedding rates and the user-newsworthiness patterns the dataset captures.</p>
<h2 id="my-contribution">My Contribution</h2>
<p>I am the second of six authors on this paper. My contributions focused on the descriptive analysis: writing code to process the collected data, generating summary tables and statistics, and helping write and review the manuscript.</p>
<h2 id="citation">Citation</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bibtex" data-lang="bibtex"><span style="display:flex;"><span><span style="color:#a6e22e">@misc</span>{mujib2020newstweetdatasetsocialmedia,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">title</span>=<span style="color:#e6db74">{NewsTweet: A Dataset of Social Media Embedding in Online Journalism}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">author</span>=<span style="color:#e6db74">{Munif Ishad Mujib and Hunter Scott Heidenreich and Colin J. Murphy and Giovanni C. Santia and Asta Zelenkauskaite and Jake Ryland Williams}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">year</span>=<span style="color:#e6db74">{2020}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">eprint</span>=<span style="color:#e6db74">{2008.02870}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">archivePrefix</span>=<span style="color:#e6db74">{arXiv}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">primaryClass</span>=<span style="color:#e6db74">{cs.SI}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">url</span>=<span style="color:#e6db74">{https://arxiv.org/abs/2008.02870}</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="related-work">Related Work</h2>
<ul>
<li><a href="/research/look-dont-tweet/">Look, Don&rsquo;t Tweet: Unified Data Models for Social NLP</a>: provides the unified cross-platform social media data model underlying broader Twitter analysis.</li>
<li><a href="/research/coordinated-social-targeting/">Coordinated Social Targeting on Twitter</a>: a companion study from the same research group and time period, documenting coordinated follower-manipulation patterns on high-profile Twitter accounts.</li>
</ul>
]]></content:encoded></item><item><title>Coordinated Social Targeting on Twitter</title><link>https://hunterheidenreich.com/research/coordinated-social-targeting/</link><pubDate>Wed, 01 Jul 2020 00:00:00 +0000</pubDate><guid>https://hunterheidenreich.com/research/coordinated-social-targeting/</guid><description>Investigation into follower dynamics on high-profile Twitter accounts, documenting sub-second spikes, saw-tooth patterns, and resurrected accounts.</description><content:encoded><![CDATA[<h2 id="abstract">Abstract</h2>
<p>In the lead-up to the 2020 U.S. presidential election, we developed tools to monitor the follower dynamics of prominent Twitter users. This work details our investigation into several strange phenomena we observed, which suggest the presence of coordinated manipulation campaigns. We present preliminary evidence of networks of automated (bot) and potentially compromised accounts aiming to artificially inflate or diminish the perceived social status of high-profile individuals. <em>(Note: this work was presented as an extended abstract at IC2S2 2020. It is available as an arXiv preprint but was not submitted as a full peer-reviewed paper.)</em></p>
<h2 id="key-contributions">Key Contributions</h2>
<ul>
<li><strong>High-frequency sampling infrastructure</strong>: custom tooling sustaining 1-second follower-count polling over weeks, recursive follower-list &ldquo;tunneling&rdquo; through Twitter&rsquo;s pagination limits, and API-rate-window management, capturing sub-second signatures invisible to standard API sampling and requiring no internal platform access.</li>
<li><strong>Four documented phenomena</strong>: spikes &amp; sawteeth, circulating accounts, resurrected pre-2010 accounts, and follower-list anomalies, each cataloged as a separate experiment.</li>
<li><strong>Preliminary evidence of coordination</strong>: anomalous patterns consistent with systematic bot activity around prominent political accounts in the lead-up to the 2020 election, presented as observation rather than proven causation.</li>
</ul>
<h2 id="methodological-strategy">Methodological Strategy</h2>
<p>To capture these transient phenomena, we built a high-frequency observational pipeline that bypassed standard sampling limitations. Our framework treated the social graph as a dynamic signal:</p>
<ol>
<li><strong>High-Resolution Sampling</strong>: Implemented &ldquo;cycling scripts&rdquo; to monitor follower counts at 1-second resolution, revealing sub-second &ldquo;spike&rdquo; anomalies often missed by aggregate metrics</li>
<li><strong>Recursive &ldquo;Tunneling&rdquo;</strong>: Developed a recursive sampling method to extract deep history from follower lists, uncovering strata of &ldquo;ancient&rdquo; accounts buried by recent activity</li>
<li><strong>Cross-Referencing</strong>: Correlated circulation events with follower count distortions to validate the &ldquo;sawtooth&rdquo; waveform hypothesis</li>
</ol>
<h2 id="key-findings">Key Findings</h2>
<ul>
<li><strong>Spikes &amp; Sawteeth</strong>: Dramatic, sub-second spikes and saw-tooth wave patterns in follower counts, suggesting automated activity</li>
<li><strong>Circulating Accounts</strong>: Cohorts of users repeatedly following and unfollowing the same high-profile accounts</li>
<li><strong>&lsquo;Resurrected&rsquo; Ancient Accounts</strong>: Disproportionate numbers of pre-2010 Twitter accounts with long inactivity periods suddenly appearing as recent followers</li>
<li><strong>Follower List Anomalies</strong>: Unusual temporal groupings in follower histories suggesting historical manipulation campaigns</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/social-targeting/elonmusk-realDonaldTrump-twitter-spikes-and-saws.webp"
         alt="Sawtooth follower growth patterns for @elonmusk and @realDonaldTrump"
         title="Sawtooth follower growth patterns for @elonmusk and @realDonaldTrump"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">High-resolution monitoring of @elonmusk and @realDonaldTrump shows &ldquo;sawtooth&rdquo; follower growth patterns consistent with (though not proof of) coordinated activity attempting to artificially inflate or suppress account metrics.</figcaption>
    
</figure>

<h2 id="significance--sociological-impact">Significance &amp; Sociological Impact</h2>
<p>Beyond the technical detection of botnets, this work raises questions about a potential vulnerability in the infrastructure of modern public discourse. The observed patterns suggest these coordinated campaigns may be targeting the metrics that algorithms use to verify relevance (such as follower counts and engagement rates) as a vector for artificially shifting perceived social proof.</p>
<ul>
<li><strong>Manipulating social-proof signals</strong>: Our observations are consistent with attempts to manipulate the &ldquo;rich-get-richer&rdquo; dynamics of social platforms. If inorganic follower inflation can influence recommender systems, that could amplify specific narratives to legitimate users. This remains a documented observation rather than a proven causal chain, and warrants further investigation.</li>
<li><strong>Account-age filters are not enough</strong>: The data highlights patterns where dormant pre-2010 accounts appear as new followers in bulk, suggesting that account-age alone is insufficient as a defense against coordinated manipulation.</li>
<li><strong>A Case for Metric Scrutiny</strong>: These findings suggest that raw follower counts are noisy signals that can be manipulated at scale. Supplementing them with engagement-quality metrics may make platform recommender systems more robust to coordinated inorganic activity.</li>
</ul>















<figure class="post-figure center ">
    <img src="/img/social-targeting/obama-hydration.webp"
         alt="UID digit distribution analysis of @BarackObama&#39;s follower list grouped by account-ID age"
         title="UID digit distribution analysis of @BarackObama&#39;s follower list grouped by account-ID age"
         
         
         loading="lazy"
         class="post-image">
    
    <figcaption class="post-caption">A &ldquo;geological&rdquo; analysis of @BarackObama&rsquo;s follower list, grouped by the number of digits in each account&rsquo;s user ID (a coarse proxy for account age). A mid-list region otherwise dominated by recent accounts abruptly inverts to much older ones, one of the anomalous patterns the work flags as consistent with (though not proof of) coordinated activity.</figcaption>
    
</figure>

<h2 id="citation">Citation</h2>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-bibtex" data-lang="bibtex"><span style="display:flex;"><span><span style="color:#a6e22e">@article</span>{heidenreich2020investigating,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">title</span>=<span style="color:#e6db74">{Investigating Coordinated &#39;Social&#39; Targeting of High-Profile Twitter Accounts}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">author</span>=<span style="color:#e6db74">{Heidenreich, Hunter Scott and Mujib, Munif Ishad and Williams, Jake Ryland}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">journal</span>=<span style="color:#e6db74">{arXiv}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">volume</span>=<span style="color:#e6db74">{abs/2008.02874}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">year</span>=<span style="color:#e6db74">{2020}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">url</span>=<span style="color:#e6db74">{https://arxiv.org/abs/2008.02874}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">eprint</span>=<span style="color:#e6db74">{2008.02874}</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">eprinttype</span>=<span style="color:#e6db74">{arXiv}</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h2 id="related-work">Related Work</h2>
<p>For related work in the computational social science cluster:</p>
<ul>
<li><a href="/research/newstweet-social-media-journalism/">NewsTweet Dataset: Social Media in Digital Journalism</a>: a companion dataset studying how tweets surface in journalism, from the same research group and time period.</li>
<li><a href="/research/look-dont-tweet/">Look, Don&rsquo;t Tweet: Unified Data Models for Social NLP</a>: provides the unified cross-platform social media data model used in broader analysis of Twitter and related platforms.</li>
<li><a href="/research/gpt2-adversarial-triggers/">GPT-2 Susceptibility to Universal Adversarial Triggers</a>: proposes UAT-based bot detection as a constructive application, addressing the same automated manipulation behavior documented here from a complementary detection angle.</li>
</ul>
]]></content:encoded></item></channel></rss>