netNodes = (await FileAttachment("data/bio_network_nodes.csv").csv()).map(d => ({term: d.term, lean: +d.lean, degree: +d.degree, x: +d.x, y: +d.y, pole: d.pole === "true"}))
netEdges = (await FileAttachment("data/bio_network_edges.csv").csv()).map(d => ({source: d.source, target: d.target, weight: +d.weight}))
bioNetwork = {
const W = Math.min(width, 760), H = Math.round(W * 0.7), pad = 28;
const q = (k, p) => d3.quantile(netNodes.map(d => d[k]).sort(d3.ascending), p);
const xs = d3.scaleLinear().domain([q("x", 0.02), q("x", 0.98)]).range([pad, W - pad]).clamp(true);
const ys = d3.scaleLinear().domain([q("y", 0.02), q("y", 0.98)]).range([H - pad, pad + 10]).clamp(true);
const color = d3.scaleDiverging([-0.09, 0, 0.13], d3.interpolateRgbBasis(["#2166AC", "#B8B8B8", "#B2182B"]));
const r = d3.scaleSqrt().domain([0, d3.max(netNodes, d => d.degree)]).range([2.5, 11]);
const adj = new Map(netNodes.map(d => [d.term, []]));
netEdges.forEach(e => { adj.get(e.source).push([e.target, e.weight]); adj.get(e.target).push([e.source, e.weight]); });
const topNbrs = t => adj.get(t).slice().sort((a, b) => b[1] - a[1]).map(d => d[0]);
const byLean = netNodes.slice().sort((a, b) => Math.abs(b.lean) - Math.abs(a.lean)).slice(0, 18).map(d => d.term);
const byDeg = netNodes.slice().sort((a, b) => b.degree - a.degree).slice(0, 8).map(d => d.term);
const baseLabels = new Set([...byLean, ...byDeg, "liberal", "conservative"]);
const leanText = d => d.pole ? `anchor for bios that name ${d.term} terms outright`
: Math.abs(d.lean) < 0.015 ? "near the middle"
: `${Math.abs(d.lean) > 0.05 ? "strongly " : ""}leans ${d.lean > 0 ? "conservative" : "liberal"}`;
const wrap = d3.create("div").style("position", "relative").style("max-width", `${W}px`);
wrap.append("div").style("font-size", "13px").style("margin-bottom", "4px").html(
`<span style="color:#2166AC">●</span> liberal-leaning <span style="color:#B8B8B8">●</span> in between <span style="color:#B2182B">●</span> conservative-leaning ▲ anchor <span style="opacity:.7">Bigger dots appear with more words.</span>`);
const svg = wrap.append("svg").attr("viewBox", [0, 0, W, H]).attr("width", W).attr("height", H)
.style("max-width", "100%").style("height", "auto").style("font-family", "Inter, sans-serif");
const link = svg.append("g").selectAll("line").data(netEdges).join("line")
.attr("x1", e => xs(netNodes.find(n => n.term === e.source).x)).attr("y1", e => ys(netNodes.find(n => n.term === e.source).y))
.attr("x2", e => xs(netNodes.find(n => n.term === e.target).x)).attr("y2", e => ys(netNodes.find(n => n.term === e.target).y))
.attr("stroke", "#8a8f98").attr("stroke-opacity", 0.07).attr("stroke-width", e => 0.5 + e.weight * 4);
const node = svg.append("g").selectAll("path").data(netNodes).join("path")
.attr("transform", d => `translate(${xs(d.x)},${ys(d.y)})`)
.attr("d", d => d3.symbol(d.pole ? d3.symbolTriangle : d3.symbolCircle, Math.PI * r(d.degree) ** 2 * (d.pole ? 1.6 : 1))())
.attr("fill", d => color(d.lean)).style("stroke", "var(--bs-body-bg)").attr("stroke-width", 0.8).style("cursor", "pointer");
const label = svg.append("g").selectAll("text").data(netNodes).join("text")
.attr("x", d => xs(d.x)).attr("y", d => ys(d.y) - r(d.degree) - 3).attr("text-anchor", "middle")
.attr("font-size", d => d.pole ? 12.5 : 10.5).attr("font-weight", d => d.pole ? 700 : 400)
.attr("fill", "currentColor").attr("paint-order", "stroke").style("stroke", "var(--bs-body-bg)").attr("stroke-width", 3)
.style("pointer-events", "none").text(d => d.term).attr("opacity", d => baseLabels.has(d.term) ? 1 : 0);
const tip = wrap.append("div").style("position", "absolute").style("pointer-events", "none").style("opacity", 0)
.style("background", "var(--bs-body-bg)").style("border", "1px solid #D9CDB2").style("border-radius", "6px")
.style("padding", "6px 9px").style("font-size", "13px").style("line-height", "1.35").style("max-width", "230px")
.style("box-shadow", "0 2px 8px rgba(0,0,0,.15)");
function focus(ev, d) {
const nb = topNbrs(d.term), shown = new Set([d.term, ...nb.slice(0, 12)]), all = new Set([d.term, ...nb]);
node.attr("opacity", o => all.has(o.term) ? 1 : 0.12);
link.attr("stroke-opacity", e => e.source === d.term || e.target === d.term ? 0.45 : 0.015);
label.attr("opacity", o => shown.has(o.term) ? 1 : 0);
const x = xs(d.x), y = ys(d.y) + 20;
tip.style("opacity", 1).style("left", `${Math.min(x + 12, W - 240)}px`).style("top", `${y}px`)
.html(`<b>${d.term}</b><br>${leanText(d)}<br>appears alongside ${nb.length} other words<br><span style="opacity:.75">most often: ${nb.slice(0, 5).join(", ")}</span>`);
}
function reset() {
node.attr("opacity", 1); link.attr("stroke-opacity", 0.07);
label.attr("opacity", d => baseLabels.has(d.term) ? 1 : 0); tip.style("opacity", 0);
}
node.on("mouseenter", focus).on("click", focus).on("mouseleave", reset);
return wrap.node();
}How Liberals and Conservatives Talk About Cancel Culture
NLP
text analytics
topic modeling
measurement validation
Python
R
HPC
8.6 million tweets. Liberal- and conservative-leaning users framed the same cancellations with different moral language, and outrage lingered for weeks after the attention faded.
In brief. Everyone has an opinion about cancel culture; very few of those opinions come with data. I collected 8.6 million tweets about cancellations from five samples: the phrase “cancel culture” itself, the “___ is over” hashtag trend, and tweets about specific academics, celebrities, and private citizens who were canceled. I estimated each user’s political lean from the words in their profile bio, then scored every tweet for moral and emotional language. Two findings stand out. Liberal- and conservative-leaning users discussed the same events in different moral vocabularies. And outrage arrived with the first wave of attention, then lingered for weeks after the attention faded.
Which words show up together in profile bios?
Each dot is a word from users’ profile bios; lines connect words that tend to appear in the same bios. Hover over (or tap) a word to see which words it travels with.
The question
“Cancel culture” gets invoked with a great deal of confidence and very little data. Underneath the argument are two questions any organization that listens to the public has to answer: who is talking, and how are different groups framing the same thing?
What I did
Estimated political lean from profile bios. A small share of users (1 to 9%) state their politics outright in their bio with terms like “#BlueWave” or “GOP.” I used them as anchors, then scored every other bio word by how closely it tracks those anchors across the millions of bios. That produced a usable estimate of partisan lean for between a third and a half of all users.
Scored moral and emotional language. I measured moral language with a published dictionary of moral words and checked it against two AI models trained to recognize similar categories. I then built an outrage score from dictionary terms for harm, cheating, betrayal, subversion, and degradation, and ran separate AI classifiers for anger, sentiment, and irony to check it.
Tracked time. For 23 cancellations with a verifiable date for the behavior itself (the tweet, remark, or incident, not the news coverage or a firing), I lined up each person’s tweets by that date and followed the outrage score for a month afterward.
Modeled topics. Using a variant of latent Dirichlet allocation, I identified different topics that were discussed by the different political groups (e.g., “mob justice”).
Note
8,572,185 English-language tweets from 4,035,488 users, collected in late 2022 through Twitter’s research API. Platform terms prohibit sharing tweet or bio text, so everything here is an aggregate. Code: github.com/zjschroeder/Cancel-Culture-Text-Analysis.
What I found
Liberal and conservative users brought different moral lenses to the same events. Each cell below compares how often conservative- and liberal-leaning users used one kind of moral language in one sample.
Red cells: conservative-leaning users used that kind of language more. Blue cells: liberal-leaning users did. The label says how many times as often (“2.1× con” means conservatives used it 2.1 times as often as liberals). Hover for the underlying rates.
Fairness showed the biggest gap, but its direction depended on who was being canceled: conservatives used it far more when the target was an academic or a private citizen, and slightly less in the other samples. Betrayal, degradation, and outrage leaned conservative in most samples.
They also talked about different parts of the story. Conservative-leaning users spent more of their tweets on free speech and on the idea of “the mob”; liberal-leaning users spent more on accountability and consequences. Although gaps are a few percentage points, for a dataset of millions of tweets, that marks a sizable difference in conversations.
Outrage arrived with the attention, then lingered.
Day 0 is the date of the behavior itself (the tweet, remark, video, or incident), found by searching news coverage for each of the 23 cases listed below. Each point averages that day and up to six days before it, counting only days from day 0 on. Days before day 0 are left off: most people in the sample were barely tweeted about before their behavior, so those averages rest on a handful of people. The academic jump around day 15 comes mostly from one case whose remarks went viral about two weeks after they aired.
The 23 cancellations in this figure
Day 0 is the date of the behavior itself, not when news covered it or when an employer acted; later actions are listed separately. Dates marked ±1 day in the data file could only be pinned that closely. Cases were left out when the behavior could not be dated to the day (for example, years-old tweets that resurfaced, or remarks recorded at an unknown time), when the only available date was news coverage or a firing, or when the dataset held fewer than 20 tweets within a month of the behavior.
An anger score is not an outrage score. An off-the-shelf AI “anger” classifier and the moral-outrage measure were essentially unrelated (r = −.05). As such, building a unique measure of outrage allowed me to specify the type and context of the negative emotionality.
What this is good for
Interviews and surveys capture what people say when a researcher asks. A dataset like this captures what they say to each other. The same approach would work on support tickets, portal comments, or reviews: estimate who is talking, compare how each group frames the same event, and watch how the tone shifts in the days after something goes wrong.
Links
- Code: github.com/zjschroeder/Cancel-Culture-Text-Analysis
- Data behind the figures: aggregate CSVs in the site repository
- Conference poster: Schroeder, Z. J. (2023). “Cancelled,” that’s good enough for me: Tracing affective flow and dehumanization in tweets participating in “cancel culture.” Society for Personality and Social Psychology, Atlanta.
- Working manuscript: Cancel Culture as Networked Moral Outrage (in preparation; ask me for the current draft)
- Method adapted from Essig, L., & DellaPosta, D. (2024). Self-presentation in Twitter bios. Scientific Reports.