Skip to content

MagicLex/empty-chair

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

38 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

empty-chair

empty-chair

awesome-ml-systems Hopsworks

Most detectors hunt the suspicious thing that is present: the nominee with 400 directorships, the offshore address. This one hunts the thing that is deliberately absent. It scores a UK company for how much its public ownership disclosure is shaped like the structures where a hidden beneficial owner was later revealed, in the ICIJ offshore leaks or on the OpenSanctions consolidated list (sanctions regimes plus debarment, crime and PEP-linked watchlists). The empty chair where an owner should be.

It reports a signal, not a verdict. A high rank means the disclosure resembles a known concealment shape, never that the company hid anyone or that any person did anything wrong. Most companies with this shape are legitimate: holding companies, family property firms, dormant shells with nothing to hide.

The result

empty_chair v5, a 10-seed LightGBM soft-vote over the 28 registry and PSC (people-with-significant-control) features plus tell interactions and grouped out-of-fold target encodings. Held out by formation-address cluster so no company mill straddles train and test. The recipe came out of an autoresearch round (22 logged experiments, autoresearch/REPORT.md) that lifted PR-AUC 31.7% over the v3 HistGradientBoosting baseline.

metric (grouped holdout, 21,350 companies) value
PR-AUC 0.377
PR-AUC lift over base rate 8.0×
ROC-AUC 0.882
precision@100 0.95
precision@1000 0.373
blind investigator rule (PR-AUC) 0.049
demographics-only control (PR-AUC) 0.070
shuffle-label control (PR-AUC) 0.050

The naive investigator rule (flag anything silent, corporate-only, or foreign-corporate) scores at the base rate: useless alone. A demographics-only model (incorporation year, sector, region) barely beats chance, so the signal sits in the shape of the disclosure. The shuffle-label run collapses to chance, so there is no leak, target encodings included. Of the 100 companies the model flags hardest, 95 are genuine later-revealed cases at a 4.8% base rate. The autoresearch round also pruned the mill-address and dormant-accounts confounds as an ablation: it cost 0.002 PR-AUC, so the model rides the PSC concealment shape, not the population confound.

Caveats

Read these before quoting the number.

  • PU-learned, the number is a lower bound. Positives are companies whose hidden interest was later revealed. Hidden owners never revealed sit unlabelled in the clean class, so every metric understates true performance.
  • The label is a name match. ICIJ and sanctions names are matched to Companies House by normalized exact match. Single-token generic names are down-weighted, but the match set carries false positives that no one has hand-verified; treat the positive labels as noisy.
  • Rank, not probability. The model is uncalibrated by design (a monotone map cannot improve a ranking metric). The app presents a population percentile, never a probability of concealment.
  • Disclosure shape, not intent. The model never sees who owns the company. A high rank is a structural resemblance, never proof of concealment.

Architecture

An FTI (feature, training, inference) system on Hopsworks. Feature extraction is one shared, pure function (chair_features.py) so training and serving cannot skew.

flowchart LR
    ch([Companies House bulk]):::ext
    icij([ICIJ leaks + OpenSanctions]):::ext
    subgraph FE[Feature]
        icij --> f1[build_labels] --> lab[(revealed_owner)]:::hops
        ch --> f2[ingest_registry] --> reg[(company_registry)]:::hops
        ch --> f3[ingest_psc] --> psc[(psc_shape)]:::hops
        lab --> f2
    end
    subgraph TR[Training]
        reg --> fv{{empty_chair_fv}}:::hops --> t[train LGBM vote + controls] --> m[(Model Registry)]:::hops
        psc --> fv
    end
    subgraph INF[Inference]
        m --> s[score_universe] --> dos[(concealment_dossiers)]:::hops
        s --> pq[(universe_scores.parquet)]:::hops --> app[emptychair app]
    end
    classDef hops fill:#10b98122,stroke:#34d399,color:#e5e7eb;
    classDef ext fill:none,stroke:#6b7280,color:#9ca3af,stroke-dasharray:4 3;
Loading

The inverse framing is the core design. The features describe the shape of the disclosure, not the owner: whether a natural person is declared at all, whether ownership routes only through corporate entities, whether the company sits silent behind a no-PSC statement, an exemption, or a super-secure protected record, whether it is registered at a formation-mill address. The label is what the disclosure was hiding, revealed after the fact.

The file-by-file map:

chair_features.py     shared, pure: CH row + PSC records -> 28 features + fired tells
build_labels.py       F1  ICIJ + sanctions -> revealed_owner (label)      (job)
ingest_registry.py    F2  CH bulk -> company_registry (case-control 20:1)  (job)
ingest_psc.py         F3  PSC snapshot -> psc_shape                        (job)
train_chair.py        T   feature view -> empty_chair + honesty controls   (job)
auditor.py            I   load model, score a company, list fired tells
score_universe.py     I1  score all 5.7M -> parquet + concealment_dossiers (job)
build_linkage.py      I2  top 1% -> shared-owner nests (linkage.parquet)   (job)
explain.py            I   plain-language dossier (Anthropic), signal not verdict
ask.py                I   ask-the-register: tool-use loop over the live data
bias_audit.py         pre-publication confound audit -> docs/bias-audit.md
app/server.py         the review app: audit, chair diagram, webs, ask
app/deploy_app.py     deploy the app

Data

All public and free. Companies House basic company data + PSC snapshot (bulk, Companies House), ICIJ Offshore Leaks database (bulk CSV), and the OpenSanctions consolidated targets export (targets.simple.csv, default dataset: sanctions plus debarment, crime and PEP-linked watchlists; GB entities only). The two heavy captures are kept out of git; build_labels and ingest_registry rebuild every feature group from them.

Reproduce

Clone into a Hopsworks project on the /hopsfs/... FUSE mount. Paths self-derive. The Anthropic key lives in a project secret (ANTHROPIC_API_KEY).

# capture the bulk data into data/ (Companies House, ICIJ, OpenSanctions)
python build_labels.py                       # F1  revealed_owner
python deploy_registry.py && hops job run ingest-registry   # F2  company_registry
python deploy_psc.py && hops job run ingest-psc             # F3  psc_shape
python deploy_train.py && hops job run train-chair          # T   empty_chair
python deploy_score.py && hops job run score-universe       # I1  scores + dossiers
python app/deploy_app.py                     # the review app

The demo

emptychair, a two-pane review. Paste any UK company number or name: the company's disclosure evidence renders on the left (every tell with its population base rate, fired ones dark), and a rank rail on the right shows the percentile stamp, the score distribution with the company pinned on it, and streams a plain-language investigator's note. When the company sits in a scored nest, its ownership web renders below: owners as squares, companies as dots colored by score, shared companies as red bridges.

/network shows the concealment webs: nests that share a company are unioned into connected graphs (the largest joins 100+ owners). All graphs are server-rendered SVG that works without JavaScript; with it, they hydrate into pan/zoom, ego-highlight on hover, tooltips, and draggable nodes over a spring simulation seeded from the server layout. The rank comes from the ML model; the note only explains it.

Every page carries Ask the register, a full-height chat drawer pinned to the right edge. The model (ask.py) answers only through deterministic tools over the live data: company lookup, ownership webs, name and owner search, population stats. No embeddings, no invented numbers; if a tool returns nothing, the answer says so. Tokens stream over a websocket (the platform proxy buffers plain HTTP streaming) with each tool call shown as a status row. The chat is contextual: it knows which page is open, and clicking any owner square in a graph points the conversation at that owner. Replies interpret the figures concretely, percentile arithmetic and tell base rates included, under the same never-accuse constraints. Without JavaScript it degrades to a full-page round trip.

Cost note: where the v5 gain lives

The 10-seed vote is the expensive part of the recipe and the smallest part of the gain. Of the +0.091 PR-AUC over the v3 baseline: +0.028 came from switching to LightGBM, +0.024 from dropping class weighting, +0.020 from dropping isotonic calibration (a monotone map cannot improve a ranking metric and its slice returns to the fit), +0.012 from interaction features and target encodings, and +0.006 from the 10-seed ensemble, inside the round's ±0.002 noise floor. The ensemble multiplies universe scoring from minutes to about 45 minutes for 5.7M companies. A single-seed variant scores 0.368 at a tenth of the cost and sits in the autoresearch_jul14 registry versions if that trade is ever preferred.

About

Which UK companies' ownership disclosure is shaped like known concealment, from the disclosure alone. ICIJ + Companies House, PR-AUC 6.1x over base rate. FTI on Hopsworks.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages