← Method & Data

Internal design document, rendered for transparency. Operational sections (deployment, job recipes, credentials) have been removed. Dated 2026-10-15; may be amended by later work recorded in the changes log.

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):

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:

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:

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:

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):

  1. 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.
  2. 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.
  3. 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)

9. Out of scope

Rendered from 2026-10-15-news-conflict-study-design.md · changes log