Tasks/Earth & Energy/Subsurface Interpretation

Classify lithology facies from wireline well logs

Classify lithology facies from wireline well logs

well_log_lithologyEarth & EnergySubsurface Interpretation
instruction.mdthis is what the agent is given

You inherit a wells-blind baseline: for every row it predicts one constant lithology class, completely ignoring every logging curve it was given. That constant is Marl, and it is not the most frequent class: the shipped training pool caps the common classes at 7,000 rows each, so eight classes are tied at the top and max(counts, key=counts.get) breaks the tie by first appearance in the CSV. Your goal is to lower the mean official 12-class lithology penalty of your predicted class against the true facies; a sealed verifier re-trains and re-runs your solver on rows from a HIDDEN, disjoint batch of wells and scores it (lower is better, 0 = every row correct).

Hard Constraints

  • You may only edit code under /app/methods/main/; you may add sibling .py modules.
  • Keep the entrypoint signatures train(train_records) -> None and predict(test_records) -> list[int], where the returned list has one entry per row of test_records, in the same order as test_records (positional, not keyed by any ID column), each entry an integer class INDEX in [0, 11] (see Class encoding below -- NOT the raw geology code).
  • The stack you develop against is byte-identical to the stack you are graded on: numpy==2.5.1, pandas==3.0.5, scikit-learn==1.9.0, scipy==1.18.0, lightgbm==4.6.0, xgboost==3.3.0, plus the standard library. Anything outside that set is not installed, and there is no network at grading time to fetch it, so importing it raises ModuleNotFoundError inside the grading subprocess and the run scores 0. Which of the installed libraries you use, and how, is entirely your call.
  • Degenerate constant predictions are rejected and score 0. A submission whose predictions use only a single class, or whose most-frequent class covers 99% or more of the graded rows, is scored 0 by the grader no matter what mean penalty it achieves. An asymmetric penalty matrix over a long-tailed label distribution pays a constant guess far more than it deserves; this task is about classifying the rows. (The shipped starter is exactly such a constant predictor, so it earns nothing by construction.) The rejection is reported, not silent.
  • Your submission is graded under a wall-clock budget. The verifier re-runs your train() + predict() in a subprocess capped at 5400 s, inside a container declared at 4 CPUs / 2048 MiB; the whole verifier stage is capped at 10800 s. The sealed re-training set is the same size as the visible /app/data/train.csv (~63,000 rows) and the sealed prediction set is ~15,000 rows (~0.24x the training set), so grading is not a larger job than your own self-check -- but your own session budget of 7200 s is 1.33x the 5400 s grading cap, and those 4 CPUs are a share of a shared machine rather than four idle cores, so a pipeline you managed to run once locally can still be killed at grading time. A run killed by the time or memory cap produces no predictions and scores 0.
  • Your solver must be deterministic given the shipped train/test data -- the verifier calls train() then predict() fresh in its own process; unseeded randomness makes your local self-check unrepresentative of the graded run (seed every model you fit).

Class encoding

train.csv's LITHOLOGY_CODE column holds the raw geology facies code (an integer like 65000), not a 0..11 index. The official code -> index map (12 classes):

30000 Sandstone -> 0     70000 Limestone -> 5      90000 Coal -> 10
65030 Sandstone/Shale -> 1   70032 Chalk -> 6       93000 Basement -> 11
65000 Shale -> 2          88000 Halite -> 7
80000 Marl -> 3           86000 Anhydrite -> 8
74000 Dolomite -> 4        99000 Tuff -> 9

predict() must return the index (right-hand column above), never the raw code. /app/lithology_map.py has this as a Python dict (CODE_TO_INDEX) for reference, but methods/main/solver.py must be self-contained -- the verifier only ships your methods/main/ directory, not /app/lithology_map.py, so copy the map into your own file if you need it at runtime (the shipped baseline already does this).

What You Have

  • /app/data/train.csv: ~63,000 labelled rows from 98 visible wells. Each row has WELL, DEPTH_MD, X_LOC/Y_LOC/Z_LOC (location), GROUP/FORMATION (stratigraphic unit names, categorical strings), 20 wireline logging curves (CALI, RSHA, RMED, RDEP, RHOB, GR, SGR, NPHI, PEF, DTC, SP, BS, ROP, DTS, DCAL, DRHO, MUDWEIGHT, RMIC, ROPA, RXO -- most have real, informative gaps: some curves are missing >80% of rows because the logging tool simply wasn't run in some wells), and the true LITHOLOGY_CODE. Class frequency is heavily long-tailed in the raw pool, but the shipped file was capped at 7,000 rows per class, and EIGHT of the twelve classes sit exactly on that cap; every row of the rarer ones is kept (as few as 103 rows for Basement). So train.csv has no meaningful majority class -- counting labels in it tells you almost nothing about the graded rows.
  • /app/data/penalty_matrix.npy: the official 12x12 asymmetric penalty matrix -- PENALTY[i, i] == 0 and off-diagonal entries range 1.375-4.0. Confusing two geologically similar facies (e.g. Limestone/Chalk, 1.375) costs far less than confusing two very different ones (e.g. Sandstone/Halite, 4.0). This is not a plain accuracy metric.
  • /app/methods/main/solver.py: the baseline solver (predicts one constant class -- Marl, the first of the eight classes tied at the 7,000-row cap -- for every row) -- this directory is what gets graded. Improve it in place or rewrite the algorithm entirely. Matching the baseline earns nothing.
  • /app/force_score.py: the exact penalty formula the verifier uses (both the formula and the penalty matrix are public; only the true held-out labels are sealed). Read it to see precisely how you are scored.
  • /app/selfcheck.py: a free, unlimited local dry-run (python /app/selfcheck.py) that fits on a well-disjoint 80/20 internal split of the visible rows and prints the proxy mean penalty. It is a proxy only -- the real held-out wells are a different, disjoint, sealed batch.

What You Submit

Edit /app/methods/main/solver.py, keeping the contract:

def train(train_records: list[dict]) -> None:
    # Called once, on the labelled re-training rows (same columns as /app/data/train.csv, string
    # values -- csv.DictReader output). Fit whatever state you need and stash it (module globals are
    # fine).
    ...

def predict(test_records: list[dict]) -> list[int]:
    # Called once, on FEATURE-ONLY rows (no label column). Return one class index [0, 11] per row,
    # same row order as test_records.
    ...

There is no submit step and no per-attempt feedback on the real held-out set -- iterate against selfcheck.py, then leave your best solver.py in place; it is graded once at the end.

How It Is Judged

After your run, the grader copies your methods/main/ into a sealed verifier, spawns a fresh process that imports it and calls train() on the sealed re-training rows, then predict() on a HIDDEN, disjoint batch of rows from wells you never saw, and scores the mean official 12-class lithology penalty of your predicted class against the true class (lower is better):

score(y_true, y_pred) = mean_i( PENALTY[y_true[i], y_pred[i]] )

Your score improves monotonically as the penalty falls, so driving the penalty down is always the goal -- 0 (every row correct) is a genuine floor, and there is real headroom between the shipped baseline and a well-tuned solution, including past what a solid off-the-shelf classifier reaches. Any crash, wrong output length, or an out-of-range class index scores the whole submission 0, and so does a degenerate prediction that fails the single-class / 99%-share guardrail stated under Hard Constraints.

Metric

mean official FORCE-2020 penalty on the sealed wells · lower is better

mean_i PENALTY[true_i, pred_i] over 15,000 hidden rows, asymmetric 12x12 lithology matrix

anchorvisible setheld-outreward
Bconstant most-frequent class3.87022.26160.00
Roff-the-shelf HGB, argmax1.57611.03980.30
Shidden SOTA blend0.98890.73410.60
Uperfect classification001.00
normalisation
m >= B0
B > m >= R0.3 * (u(B) - u(m)) / (u(B) - u(R))
R > m >= S0.3 + 0.3 * (u(R) - u(m)) / (u(R) - u(S))
m < S0.6 + 0.4 * (u(S) - u(m)) / (u(S) - u(U))

m = this run's held-out metric  ·  B = constant most-frequent class  ·  R = off-the-shelf HGB, argmax  ·  S = hidden SOTA blend  ·  U = perfect classification

u(x) = log(x + 1). One sealed set, so mapped once, floored at 0 and capped at 1. A degenerate prediction forces reward 0.

Rollouts

185 minwall clock
$44.47spend
70.8Mtokens
13versions, 13 kept
0.75 1.50 2.25 3.00 3.75 $0 $10 $20 $30 $40 cumulative spend on the run seed-0 self-check mean penalty, lower is better hidden SOTA blend · visible · 0.9889 v0 v1 v2 v3 v4 v5 v6 v7 v8 v9 v10 v11 v12
keptrevertedno scoreturning point
  1. v0Shipped constant-Marl baseline; no curve is read3.87021 min · $0.23
  2. v1LightGBM on raw curves + GROUP/FORMATION, Bayes minimum-expected-penalty pickPick the class with the lowest expected penalty under the matrix, not the most likely class.1.01834 min · $0.51
  3. v2Dense GR/DTC/CALI only, so sparse-curve gaps cannot identify a well1.056311 min · $1.47
  4. v3Equal-weight probability ensemble of the dense-core and all-curve models0.936414 min · $1.97
  5. v4Add 30% smoothed FORMATION-to-GROUP class prior; 40/60 core/full blend0.919221 min · $2.97
  6. v5Inverse-retention class prior from depth-gap statistics, exponent 0.35The shipped pool is capped at 7,000 rows per class and the graded wells are not, so reconstruct the uncapped mix.0.97444 min · $7.29
  7. v6Retention re-estimated from untruncated same-label runs; exponent 0.501.086557 min · $10.04
  8. v7Retune on natural splits: core/full 25/75, retention exponent 0.551.123461 min · $11.35
  9. v8Fewer boosting rounds: core 450 to 200, full 450 to 3001.116772 min · $14.58
  10. v9Regularize the core view only: 23 leaves, min child 50, lambda 21.103578 min · $16.41
  11. v10Entropy gate replaces the fixed core weight, clipped to [0.10, 0.70]1.0767100 min · $23.01
  12. v11Twelve regressors estimate each action's penalty, blended 80% with posterior costRegress the penalty of every legal class directly instead of reading it off predicted probabilities.0.9608162 min · $40.70
  13. v12Direct-cost sample weights move to full inverse retention1.035173 min · $42.38

v0-v4 took 21 min and $2.97; v5-v12 took 152 more and $39.4. Self-check rose after v4 and the agent kept going anyway.

On the hidden set

held-out metricreward
constant most-frequent class2.26160.00
off-the-shelf HGB, argmax1.03980.30
hidden SOTA blend0.73410.60
perfect classification01.00
this run0.5838330.6659