11.27.6. Learned Dimensionality Classification#

classify_dimensionality() labels each (station, frequency) observation 1-D, 2-D, or 3-D from two thresholds on phase-tensor skew and ellipticity – transparent, but a hard boundary at a fixed skew value treats a sample just below the cutoff identically to one far below it, and ignores the other three phase-tensor-derived quantities that phase_features_table() already computes. DimensionalityClassifier is a multi-layer perceptron (MLP) trained on all five features at once – \([\,|\beta|,\ \text{ellipticity},\ \log_{10}\rho_{\det},\ \phi_{\det},\ |T|\,]\) – with two output heads sharing one backbone: a 3-class softmax for dimensionality, and a linear regression head for geoelectric strike on the 2-D subset.

11.27.6.1. Self-training from the rule-based labels#

from_features_table() builds training labels from the same rule-based thresholds it is meant to refine when no label_col is supplied – the network’s job is not to invent a new definition of dimensionality, but to interpolate the rule-based boundary more smoothly across all five features jointly, rather than two features and a hard cutoff.

>>> from pycsamt.emtools.dimensionality import phase_features_table
>>> feats = phase_features_table(sites)
>>> len(feats), feats["station"].nunique()
(1484, 28)

Warning

The strike head is a separate branch that only receives gradient updates through its own loss term, added only when real strike targets are supplied via strike= (in fit()) or strike_col= (in from_features_table()). Call from_features_table(feats) with neither, as the self-training description above might suggest, and the strike head trains on nothing at all – predict_strike() still returns numbers, but they carry no information and should not be interpreted physically. Always supply real strike targets when the predicted strike matters, as done next.

11.27.6.2. Supervising the strike head with a classical estimate#

estimate_strike_phase_tensor() computes one classical, per-station consensus strike angle from the same phase-tensor table. Broadcasting it back onto every frequency row of its station turns it into a real strike_col teacher signal – the strike-head counterpart of the rule-based dimensionality labels:

>>> from pycsamt.emtools.strike import estimate_strike_phase_tensor
>>> from pycsamt.ai.processing import DimensionalityClassifier
>>> consensus = estimate_strike_phase_tensor(sites)
>>> consensus[["station", "ang", "iqr"]].head(3)
  station        ang        iqr
0 18-001A -50.071764  28.974476
1 18-002U -43.757578  16.329767
2 18-003A -38.949283  18.993311
>>> feats = feats.merge(
...     consensus[["station", "ang"]], on="station", how="left",
... ).rename(columns={"ang": "strike_target"})
>>> clf = DimensionalityClassifier.from_features_table(
...     feats, strike_col="strike_target", epochs=120, seed=0,
...     verbose=False,
... )
DimensionalityClassifier(n_classes=3, torch, fitted)
>>> table = clf.predict_table(sites)
>>> table["dim_label"].value_counts()
3D    1368
2D     116
Name: count, dtype: int64
>>> n2d = int((table["dim"] == 1).sum())
>>> table.loc[table["dim"] == 1, "strike"].mean(), \
...     table.loc[table["dim"] == 1, "strike"].std()
(-42.9, 3.3)
>>> consensus["ang"].mean()
-39.4

Averaged over the 116 samples the network itself classified as 2-D, the predicted strike (\(-42.9^\circ \pm 3.3^\circ\)) lands within four degrees of the classical per-station consensus averaged over all 28 stations (\(-39.4^\circ\)) – direct evidence that the strike head learned a physically meaningful regression rather than collapsing to an arbitrary constant, which is precisely the failure mode the warning above describes for the unsupervised case.

../../_images/dimensionality_summary.png

plot_dimensionality_summary() – the station x frequency class map, class proportions, and the 2-D strike rose (folded to the standard 180-degree line-ambiguity convention, with the circular mean overlaid as a dashed line).#

L18PLT classifies as predominantly 3-D (92%), with a 2-D minority (8%) concentrated at short period along most of the line and a handful of long-period 2-D windows near the profile’s last few stations. This matches the picture built independently in Machine-Learning Quality Scoring: the stations whose median QC score collapsed under the hard skew rule are exactly the ones a genuinely 3-D subsurface response would be expected to produce. 1-D observations are rare in the rule-based training labels themselves – well under 5% of the L18PLT dataset – and this run predicted none at all, which is not a bug: with training labels this imbalanced, a self-trained network is not guaranteed to recover the minority class every time. If 1-D detection matters for your survey, check the raw rule-based label balance first and consider class weighting.

Note

The exact counts above will drift a little from run to run – DimensionalityClassifier’s PyTorch weight initialization is not seeded (the same caveat noted for AnomalyDetector in Profile-Level Anomaly Detection), so fit(seed=0) reproduces the data split exactly but not the network’s starting point. The qualitative picture – overwhelmingly 3-D, a real 2-D minority near short period, an unreliable 1-D minority, and a predicted strike within a few degrees of the classical consensus – is stable across runs even when the exact tallies are not.

../../_images/dimensionality_training.png

plot_training_history() – both curves report classification cross-entropy only, on the same scale, so they can be compared directly even though the strike MSE term also contributes to the gradient during training.#

11.27.6.3. Parameters#

hidden (default (128, 64)) sets the shared-backbone width; dropout (default 0.2) regularizes it. The strike loss weight is fixed at 0.1 relative to the classification cross-entropy, low enough that strike supervision refines the shared features without dominating the primary classification objective. Falls back to a random-forest classifier (scikit-learn) when neither PyTorch nor TensorFlow is installed – predict_strike() then always returns NaN, since the fallback has no regression head at all.

Full signatures: DimensionalityClassifier, phase_features_table(), estimate_strike_phase_tensor(), plot_dimensionality_map(), plot_predicted_strike_rose(), and plot_dimensionality_summary().

Note

plot_predicted_strike_rose() draws a rose from an already-computed array of predicted angles (as returned by predict_table()) and is intentionally distinct from plot_strike_rose(), which recomputes strike from a site collection using the classical estimators described in Geoelectric Strike.

View the complete DimensionalityClassifier example sourceClick to inspect and copy the complete code
 1def run_classify(sites, save_dir: Path) -> None:
 2    print("\n=== DimensionalityClassifier ===")
 3    feats = phase_features_table(sites)
 4    print(f"  feature rows: {len(feats)}")
 5    print(f"  stations: {feats['station'].nunique()}")
 6
 7    # Classical, per-station phase-tensor strike (emtools.strike) supplies
 8    # the strike-head training target.  Without it, from_features_table's
 9    # default self-training path (rule-based dimensionality labels only)
10    # never supervises the strike head at all -- see the note below.
11    consensus = estimate_strike_phase_tensor(sites)
12    feats = feats.merge(
13        consensus[["station", "ang"]], on="station", how="left"
14    ).rename(columns={"ang": "strike_target"})
15    print("  classical (phase-tensor) strike per station -- head/tail:")
16    print(
17        consensus[["station", "ang", "iqr"]]
18        .head(3)
19        .to_string(index=False)
20    )
21
22    clf = DimensionalityClassifier.from_features_table(
23        feats, strike_col="strike_target", epochs=120, seed=0,
24        verbose=False,
25    )
26    print(f"  {clf!r}")
27    hist = clf.history_
28    if hist.get("val_loss"):
29        print(f"  final val_loss: {hist['val_loss'][-1]:.5f}")
30
31    table = clf.predict_table(sites)
32    counts = table["dim_label"].value_counts()
33    print("  predicted class counts:")
34    print(counts.to_string())
35    n2d = int((table["dim"] == 1).sum())
36    if n2d:
37        strike_mean = table.loc[table["dim"] == 1, "strike"].mean()
38        strike_std = table.loc[table["dim"] == 1, "strike"].std()
39        print(f"  predicted strike over {n2d} 2-D samples: "
40              f"{strike_mean:.1f} +/- {strike_std:.1f} deg")
41        print(f"  classical consensus (all stations): "
42              f"{consensus['ang'].mean():.1f} deg")
43
44    fig = plot_dimensionality_summary(
45        table, suptitle="DimensionalityClassifier -- L18PLT"
46    )
47    _save(fig, save_dir, "dimensionality_summary.png")
48
49    ax = plot_training_history(
50        clf, title="DimensionalityClassifier training"
51    )
52    _save(ax.figure, save_dir, "dimensionality_training.png")