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.pymodules. - Keep the entrypoint signatures
train(train_records) -> Noneandpredict(test_records) -> list[int], where the returned list has one entry per row oftest_records, in the same order astest_records(positional, not keyed by any ID column), each entry an integer class INDEX in[0, 11](seeClass encodingbelow -- 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 raisesModuleNotFoundErrorinside 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()thenpredict()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 hasWELL,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 trueLITHOLOGY_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). Sotrain.csvhas 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] == 0and 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.