News ↔ conflict descriptive study — design
1. Purpose
Describe how the news channels we ingest relate to conflict on the ground in Mali, Niger and Burkina Faso, using the two tensors the pipeline now publishes nightly: s_tensor (labeled stories: actor × variant × function × theme × admin1 × day) and c_tensor (UCDP GED events: admin1 × day × type of violence). The reader is the PI. Success is a small set of defensible descriptive findings with their limits stated as plainly as the findings, a reusable joined panel the later analysis phases can read, and a report that can be regenerated with one command when the labeled corpus grows.
Two tiers, decided because label coverage bounds the located slice (§1.1):
- Composition (headline): what kind of news appears when violence rises — theme share, function mix, alignment split — on the labeled corpus, Sahel-wide by week.
- Timing (secondary, caveated): whether regional news attention moves with, before or after regional violence — on label-free located volume, by admin1 and month.
Everything is descriptive. No causal claim is made or implied (§8).
1.1 Facts that shaped the design (prod, pipeline_version 0.1.0, 2026-10-15)
| Quantity | Value |
|---|---|
| Variants admissible to s_tensor (function + theme labels, not needs_human) | 1,953 of 4,541 |
| Variants with no labels at all | 2,493 (hold 53 % of docs, 79 % of geo-located docs) |
| Docs in s_tensor / cells | 436,533 / 263,829 |
| s_tensor member docs with an admin1 | 6,788 (13,819 mentions, 30 GADM admin1 units) |
| Geo-located docs in doc_geo (all variants, labeled or not) | 40,956 (60,281 mention rows) |
| Located docs by year 2021→2026 (doc_geo) | 943 · 1,864 · 3,661 · 6,899 · 9,531 · 15,173 |
| UCDP events in window / country-only ("unlocated") | 5,445 / 852 (16 %) |
| c_tensor cells | 4,547 (2021-01-02 .. 2026-08-31) |
| admin1 codes shared by doc_geo and c_tensor | 27 (3 regions have mentions but no UCDP events) |
The newer cluster generations are the unlabeled ones, so composition on labels is dense only Sahel-wide, while timing on labels alone would be truncated to ~6.8k docs. Hence the label-free attention tensor (§2).
2. Attention tensor a_tensor (label-free sibling of s_tensor)
2.1 Tables (migration 0022)
a_tensor(id bigserial pk, actor_id text, admin1 text, day date, doc_count int, view_sum bigint, pipeline_version text, run_id text, created_at timestamptz default now()), UNIQUE (actor_id, admin1, day, pipeline_version), indexes on admin1 and day.
a_tensor_doc(tensor_id bigint references a_tensor(id) on delete cascade, channel_id bigint, msg_id bigint, article_id bigint, doc_id text generated as in s_tensor_doc, primary key (tensor_id, doc_id)), partial indexes on (channel_id, msg_id) and (article_id) as for s_tensor_doc.
Downgrade drops both tables.
2.2 What enters
Every document with judgeable content, regardless of clustering or labels:
- a Telegram message with non-empty
text, or with a non-exploratory, non-empty transcript at the pipeline version (same EXISTS belt as s_tensor), or - any article.
Day = message.ts, else article.published_ts, else article.scraped_ts, as a date. Actor = 'tg:' || channel_id or 'web:' || site_id ('web:unknown' when the site is null), identical to s_tensor. Geolocation is mention-based through doc_geo at the pipeline version: a doc naming two admin1 units contributes to both cells; a doc with no doc_geo row lands in admin1 = 'unlocated'. No near-duplicate collapsing, so a_tensor and s_tensor stay comparable; the report states this.
2.4 Doc-level exports (dedupe companions)
Cells are mention-based; deduplicating to distinct docs needs the bridge, which Parquet consumers do not have. The export therefore writes two companions:
s_docs.parquet— one row per (doc, variant):doc_id, actor_id, variant_id, function, theme, alignment, day, n_regions(alignment = the variant's vetted actor_alignment label, null when none — amended 2026-10-15 at plan time, the composition split needs it; n_regions = number of admin1 cells the doc fans into, 0 when unlocated). ≈ 437k rows.a_docs.parquet— one row per doc:doc_id, actor_id, day, n_regions. ≈ 950k rows.
Both derive from the bridges in SQL at export time. doc_id is the public post identifier already stored in the bridges; no personal data is added.
2.5 Export
3. Panels
Built by src/sahel_de/analysis/news_conflict/panels.py from the five Parquets (s_tensor, c_tensor, a_tensor, s_docs, a_docs). Country is the GADM admin1 code prefix (BFA., MLI., NER.). Nothing reads Postgres.
Common rules:
- Window: 2021-01-01 to the last complete month (Panel A) / week, Monday-start ISO (Panel B) before the run date; the partial period is dropped and named in the manifest.
- Regional attention = mentions (a_tensor cells); every denominator and every composition count = distinct docs (docs tables).
- Country-only UCDP events (
c_tensor.admin1 = 'unlocated') count at country level and never in a region's rate. - Measures are shares; raw counts appear only beside a coverage line.
3.1 Panel A — panel_admin1_month (timing)
One row per located admin1 (the 30 GADM units with any mention or event) × month.
| column | definition |
|---|---|
| country, admin1, month | keys |
| ucdp_events, ucdp_events_tov{1,2,3}, deaths_best | located c_tensor rows summed |
| mentions | a_tensor doc_count for the admin1 (excluding unlocated) |
| mention_share | mentions / sum of located mentions in the same country-month |
| labeled_mentions | s_tensor doc_count summed over variants for the admin1 |
| country_unlocated_events, country_unlocated_share | country-month context, repeated |
| active_actors, docs_total | country-month coverage from a_docs: actors with ≥ 1 located doc in the country, and distinct located docs. Unlocated docs have no country and enter only the Sahel-wide coverage (Panel B); the column docstrings say so |
3.2 Panel B — panel_week (composition, Sahel-wide) and panel_week_country
One row per ISO week (Sahel-wide), and per (country, week) built from mention-located docs only.
| column | definition |
|---|---|
| week (, country) | keys |
| labeled_docs | distinct docs from s_docs (a doc in several variants counts once) |
| share_security_conflict | labeled docs whose theme is security-conflict / labeled_docs |
| fn_<function> (6 columns) | within security-conflict docs, share per function |
| fn_<function>__<alignment> | the same mix split by actor_alignment label, categories as in the label set |
| ucdp_events, deaths_best | all c_tensor rows incl. country-only |
| active_actors, docs_total | coverage from a_docs (all docs Sahel-wide; located docs per country) |
3.3 Fingerprints and manifest
Each panel gets a content md5 over its ordered rows; _fingerprint.json lists them. _runs/<run_id>.json records: run id and UTC time, input Parquet fingerprints and their tensor run ids, labeled-corpus stamp (labeled docs, admissible variants, as-of date), last complete month and week, excluded regions, and the cannot-claim list verbatim (§8).
4. Analyses (stats.py)
All descriptive; intervals, not p-values. Spearman throughout.
Composition (Panel B, Sahel-wide; per-country in the appendix):
- Security-conflict share vs violence: lags −8..+8 weeks, on levels and on week-to-week differences; moving-block bootstrap (1,000 resamples, block = 8 weeks, fixed seed) for the interval at each lag; report the peak lag and whether its interval excludes zero.
- Framing when violence rises: high-violence weeks = top quartile of ucdp_events; table of mean function shares (within security-conflict) in high vs rest with bootstrap intervals of the difference.
- Framing by alignment: result 2 split by actor_alignment category.
Timing (Panel A): per region, cross-correlation of ucdp_events against mention_share at lags −3..+3 months, levels and differences. Summaries: histogram of per-region peak lags; pooled within-region estimate (region-demeaned series, region-block bootstrap, 1,000 resamples). Regions with fewer than 12 months having any mention are excluded and listed. Appendix: the same at admin1-week, lags −8..+8.
Guards: a correlation is computed only when ≥ 24 overlapping observations remain after lagging; otherwise the cell is insufficient. Any statistic that cannot be computed is printed as such, never dropped silently.
Coverage (always first): labeled share of docs by year, country, actor; located share by year; active actors per week; country-only UCDP share per country and year.
5. Report and outputs (report.py, scripts/analysis/news_conflict.py)
CLI: uv run python scripts/analysis/news_conflict.py {panels|report|all} --run-id nc-YYYYMMDD --out DIR [--from-local DIR]. --from-local reads the five tensors from a folder so tests and dry runs never touch S3. S3 access through Settings / ObjectStore.
Report, one plain-language HTML page: (1) what this is and is not, then the cannot-claim list; (2) coverage tables; (3) composition, three results, one figure or table each with two or three sentences and the interval; (4) timing: peak-lag histogram, pooled estimate, two aligned region×month heatmaps (events, mentions, same row order), excluded regions; (5) methods in under a page; (6) provenance: run id, input fingerprints, tensor run ids, labeled-corpus stamp. Appendix: per-country composition, admin1-week sensitivity. Figures follow the project's dataviz guidance: no dual axes, aligned small multiples, one palette. The renderer refuses to run if the panels' fingerprints do not match the run manifest.
8. Cannot-claim list (verbatim in the report and the run manifest)
- No causal claim in either direction. Correlations and lead-lag peaks describe co-movement, not influence.
- No claim about the population of Sahel media. The corpus is the registry we chose, added in waves, and shares are shares of that corpus.
- No claim about what unlabeled stories say. Composition covers the labeled variants as of the run date, and the labeled share is reported per year, country and actor.
- No claim about regions where news or events were not geolocated. Country-only UCDP events and unlocated docs are counted Sahel-wide or at country level only, never in a region, and their shares are shown. (amended 2026-10-15 at review: c_tensor carries no country for country-only events, so they count Sahel-wide.)
- No claim about precise timing below a month at regional level, or below a week nationally.
- No claim that a doc mentioning a region is about that region. Geolocation is mention-based, so regional attention means "named the region".
- No comparison with ACLED. It is a frozen reference, not part of this series.
- No claim about intent, coordination, or who is behind a channel. Alignment is a label on stories, not an attribution of actors.