๐ŸŒ EN | ๐Ÿ‡ฏ๐Ÿ‡ต JP | Materials Informatics Quickstart

Hands-On MI in One Hour: Predicting Band Gaps from Composition

From a raw compound list to a trained model and an honest evaluation, all in one sitting

๐Ÿ“– Time: 60-90 min ๐Ÿ“Š Level: Beginner ๐Ÿ’ป Code cells: 6 ๐Ÿงช Data: 30 measured compounds

Learning Objectives

By the end of this hands-on, you will be able to do three things.

0. Introduction: Where This Page Fits

Materials Informatics (MI) is the field that uses data and machine learning to predict the properties of new materials and to narrow down promising candidates. Reading a definition takes you only so far; running a model with your own hands is what makes it click. This page is a self-contained hands-on that lets you experience that first step in 60 to 90 minutes.

The task: predict a compound's band gap (the minimum energy, in eV, an electron needs to jump from the valence band to the conduction band) from its composition alone. The band gap is one of the most fundamental properties in semiconductor and insulator design. Silicon's 1.12 eV governs the performance of solar cells and integrated circuits; magnesium oxide's 7.8 eV makes it a transparent insulator.

๐Ÿ“‹ Prerequisites and setup

1. Prepare the Data (Data)

Every MI project starts with data. Here we use experimental band gaps of 30 real semiconductors and insulators, all values you can verify in textbooks and semiconductor handbooks. Each is a representative room-temperature experimental value.

โš ๏ธ On data honesty (important)

The values below are representative figures from standard references such as C. Kittel, Introduction to Solid State Physics, and semiconductor handbooks. Band gaps vary slightly with measurement temperature, sample quality, technique, and source (often by roughly plus or minus 0.05 to 0.1 eV). Treat them as "about this value." When you use such data in research, always check the value and its measurement conditions against a primary source. Never fabricate numbers to fit: honest data is the first habit to build in MI.

import re
import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import LeaveOneOut, cross_val_predict, cross_val_score
from sklearn.metrics import mean_absolute_error, r2_score

# Experimental band gaps (room temperature, representative eV).
# Sources: Kittel, Introduction to Solid State Physics;
#          standard semiconductor handbook values.
dataset = [
    ("Si", 1.12), ("Ge", 0.67), ("C", 5.47), ("SiC", 3.00),
    ("GaAs", 1.42), ("GaP", 2.26), ("GaN", 3.40), ("GaSb", 0.73),
    ("InP", 1.35), ("InAs", 0.36), ("InSb", 0.17), ("InN", 0.70),
    ("AlAs", 2.16), ("AlSb", 1.60), ("AlP", 2.45),
    ("ZnO", 3.37), ("ZnS", 3.60), ("ZnSe", 2.70), ("ZnTe", 2.25),
    ("CdS", 2.42), ("CdSe", 1.74), ("CdTe", 1.49),
    ("PbS", 0.37), ("PbSe", 0.27), ("PbTe", 0.32),
    ("MgO", 7.80), ("TiO2", 3.20), ("SnO2", 3.60),
    ("Cu2O", 2.17), ("BN", 6.40),
]
print(f"Number of compounds: {len(dataset)}")
print("Min:", min(dataset, key=lambda x: x[1]))
print("Max:", max(dataset, key=lambda x: x[1]))

Output:

Number of compounds: 30
Min: ('InSb', 0.17)
Max: ('MgO', 7.8)

The set spans wide-gap materials such as diamond (C, 5.47 eV) and cubic boron nitride (BN, 6.4 eV) all the way down to narrow-gap indium antimonide (InSb, 0.17 eV), used in infrared detectors. This wide spread of values matters later, when we examine where the model is strong and where it struggles.

2. Turn Composition into Numbers (Featurization, part 1: the parser)

A machine-learning model cannot make sense of the string "GaAs" as-is. It needs a sequence of numbers (a vector). Producing that is called featurization (computing descriptors), and it is the heart of MI.

First we write a small parser that breaks a formula into "which element, how many atoms." To do that we also prepare a table of element properties, all tabulated experimental values from the periodic table and chemistry handbooks.

# Element property table (tabulated experimental values).
# Z=atomic number, period, group, EN=Pauling electronegativity, mass=atomic mass
ELEMENTS = {
    "B":  {"Z": 5,  "period": 2, "group": 13, "EN": 2.04, "mass": 10.81},
    "C":  {"Z": 6,  "period": 2, "group": 14, "EN": 2.55, "mass": 12.011},
    "N":  {"Z": 7,  "period": 2, "group": 15, "EN": 3.04, "mass": 14.007},
    "O":  {"Z": 8,  "period": 2, "group": 16, "EN": 3.44, "mass": 15.999},
    "Mg": {"Z": 12, "period": 3, "group": 2,  "EN": 1.31, "mass": 24.305},
    "Al": {"Z": 13, "period": 3, "group": 13, "EN": 1.61, "mass": 26.982},
    "Si": {"Z": 14, "period": 3, "group": 14, "EN": 1.90, "mass": 28.085},
    "P":  {"Z": 15, "period": 3, "group": 15, "EN": 2.19, "mass": 30.974},
    "S":  {"Z": 16, "period": 3, "group": 16, "EN": 2.58, "mass": 32.06},
    "Ti": {"Z": 22, "period": 4, "group": 4,  "EN": 1.54, "mass": 47.867},
    "Cu": {"Z": 29, "period": 4, "group": 11, "EN": 1.90, "mass": 63.546},
    "Zn": {"Z": 30, "period": 4, "group": 12, "EN": 1.65, "mass": 65.38},
    "Ga": {"Z": 31, "period": 4, "group": 13, "EN": 1.81, "mass": 69.723},
    "Ge": {"Z": 32, "period": 4, "group": 14, "EN": 2.01, "mass": 72.630},
    "As": {"Z": 33, "period": 4, "group": 15, "EN": 2.18, "mass": 74.922},
    "Se": {"Z": 34, "period": 4, "group": 16, "EN": 2.55, "mass": 78.971},
    "Cd": {"Z": 48, "period": 5, "group": 12, "EN": 1.69, "mass": 112.414},
    "In": {"Z": 49, "period": 5, "group": 13, "EN": 1.78, "mass": 114.818},
    "Sn": {"Z": 50, "period": 5, "group": 14, "EN": 1.96, "mass": 118.710},
    "Sb": {"Z": 51, "period": 5, "group": 15, "EN": 2.05, "mass": 121.760},
    "Te": {"Z": 52, "period": 5, "group": 16, "EN": 2.10, "mass": 127.60},
    "Pb": {"Z": 82, "period": 6, "group": 14, "EN": 2.33, "mass": 207.2},
}

def parse_formula(formula):
    """Parse a chemical formula into a {element: atom_count} dict."""
    tokens = re.findall(r"([A-Z][a-z]?)(\d*)", formula)
    comp = {}
    for elem, count in tokens:
        if elem == "":
            continue
        n = int(count) if count else 1
        comp[elem] = comp.get(elem, 0) + n
    return comp

# Sanity check
for f in ["Si", "GaAs", "TiO2", "Cu2O"]:
    print(f, "->", parse_formula(f))

Output:

Si -> {'Si': 1}
GaAs -> {'Ga': 1, 'As': 1}
TiO2 -> {'Ti': 1, 'O': 2}
Cu2O -> {'Cu': 2, 'O': 1}

The regular expression ([A-Z][a-z]?)(\d*) captures the pattern "one uppercase letter, an optional lowercase letter (the element symbol), followed by zero or more digits (the atom count)." TiO2 becomes {'Ti': 1, 'O': 2} and Cu2O becomes {'Cu': 2, 'O': 1}. We now have the composition ratios.

๐Ÿ’ก In practice

This hand-written parser is a minimal teaching implementation. In real research, the Composition class in pymatgen handles complex formulas with parentheses (e.g. Ca(OH)2), hydrates, and oxidation states correctly. For the next step, matminer generates well over a hundred descriptors in a single line via ElementProperty.from_preset("magpie"). We write it by hand here purely to feel what happens under the hood.

3. Compute the Descriptors (Featurization, part 2)

With the composition ratios in hand, we take composition-weighted averages of the element properties to condense each compound into a numeric vector. We build these six descriptors.

FEATURE_NAMES = ["mean_EN", "EN_diff", "mean_Z", "mean_group", "mean_period", "mean_mass"]

def featurize(formula):
    comp = parse_formula(formula)
    total = sum(comp.values())
    fracs = {e: n / total for e, n in comp.items()}
    ens = [ELEMENTS[e]["EN"] for e in comp]
    mean_EN = sum(fracs[e] * ELEMENTS[e]["EN"] for e in comp)
    EN_diff = max(ens) - min(ens)
    mean_Z = sum(fracs[e] * ELEMENTS[e]["Z"] for e in comp)
    mean_group = sum(fracs[e] * ELEMENTS[e]["group"] for e in comp)
    mean_period = sum(fracs[e] * ELEMENTS[e]["period"] for e in comp)
    mean_mass = sum(fracs[e] * ELEMENTS[e]["mass"] for e in comp)
    return [mean_EN, EN_diff, mean_Z, mean_group, mean_period, mean_mass]

X = np.array([featurize(f) for f, _ in dataset])
y = np.array([g for _, g in dataset])
print("Feature matrix X shape:", X.shape)
print("Feature names:", FEATURE_NAMES)
print("GaAs features:", [round(v, 3) for v in featurize("GaAs")])
print("MgO  features:", [round(v, 3) for v in featurize("MgO")])

Output:

Feature matrix X shape: (30, 6)
Feature names: ['mean_EN', 'EN_diff', 'mean_Z', 'mean_group', 'mean_period', 'mean_mass']
GaAs features: [1.995, 0.37, 32.0, 14.0, 4.0, 72.322]
MgO  features: [2.375, 2.13, 10.0, 9.0, 2.5, 20.152]

We now have a 30-by-6 matrix X. Look at EN_diff: covalent GaAs has a small electronegativity difference of 0.37, whereas ionic MgO has a large one of 2.13. Whether that difference maps onto the size of the band gap is exactly what we now ask the model to learn. y is the vector of the values we want to predict (the band gaps).

4. Train and Evaluate the Model (Training, Evaluation)

For the model we use a random forest regressor (an average over many decision trees). It resists overfitting on small data, needs no preprocessing (no standardization), and lets us read out feature importances afterward, making it an easy first choice.

How we evaluate matters just as much. We have only 30 samples. A plain train/test split would leave just a handful of test points and give a jittery estimate. So we use leave-one-out cross-validation (LOO-CV): hold out one compound as the test, train on the remaining 29, and repeat this for every compound. Because each compound is tested exactly once as an "unseen material," the estimate stays stable even on tiny data.

โš ๏ธ Why cross-validation matters so much

If you measure performance on the same data the model was trained on, the model may simply be recalling answers, and the apparent score becomes unrealistically good. This is data leakage, the most common mistake in MI. Only by measuring how well the model does on data it has never seen do you learn its true ability. The smaller the dataset, the more this evaluation design drives the outcome.

model = RandomForestRegressor(n_estimators=300, random_state=42)
loo = LeaveOneOut()
y_pred_cv = cross_val_predict(model, X, y, cv=loo)
mae = mean_absolute_error(y, y_pred_cv)
r2 = r2_score(y, y_pred_cv)
print(f"LOO-CV MAE: {mae:.3f} eV")
print(f"LOO-CV R^2: {r2:.3f}")

# For reference: 5-fold CV
model5 = RandomForestRegressor(n_estimators=300, random_state=42)
scores = cross_val_score(model5, X, y, cv=5, scoring="neg_mean_absolute_error")
print(f"5-fold CV MAE: {-scores.mean():.3f} eV (std {scores.std():.3f})")

Output:

LOO-CV MAE: 0.892 eV
LOO-CV R^2: 0.471
5-fold CV MAE: 0.834 eV (std 0.343)

Let us read the numbers. The MAE (mean absolute error) is about 0.89 eV, meaning predictions are off from the measured value by 0.89 eV on average. The R-squared (coefficient of determination, closer to 1 is better) is 0.47: a perfect model scores 1.0, a useless one hovers near 0. 0.47 is an honest "captures the trend but is still coarse." The 5-fold cross-validation gives a similar MAE of 0.83 eV, confirming the estimate is not a fluke.

You may feel that score is low. But the right way to see it is: we got this far using composition only, 30 samples only, and six hand-made descriptors only. The next section shows exactly where the room for improvement lies.

5. Check Predictions One by One (Visualizing the Evaluation)

The average MAE alone does not tell us which materials the model nailed and which it missed. Let us line up the cross-validated predictions against the measured values for eight representative compounds.

print(f"{'Compound':<10}{'Actual(eV)':>12}{'Pred(eV)':>10}{'Error':>8}")
show = ["Si", "GaAs", "GaN", "ZnO", "CdTe", "MgO", "PbS", "InSb"]
idx = {f: i for i, (f, _) in enumerate(dataset)}
for f in show:
    i = idx[f]
    print(f"{f:<10}{y[i]:>12.2f}{y_pred_cv[i]:>10.2f}{y_pred_cv[i]-y[i]:>+8.2f}")

Output:

Compound    Actual(eV)  Pred(eV)   Error
Si                1.12      2.46   +1.34
GaAs              1.42      1.37   -0.05
GaN               3.40      2.81   -0.59
ZnO               3.37      3.16   -0.21
CdTe              1.49      1.06   -0.43
MgO               7.80      3.04   -4.76
PbS               0.37      0.68   +0.31
InSb              0.17      0.73   +0.56

A telling result. GaAs (error -0.05), ZnO (-0.21), and GaN (-0.59) are predicted well. Each has several compositional cousins (III-V semiconductors or oxides) in the dataset, so the model could interpolate from "nearby" materials.

By contrast, MgO missed by 4.76 eV: a measured 7.80 eV against a predicted 3.04 eV. MgO has by far the largest band gap in the data, and a random forest cannot extrapolate beyond the range of its training data (a tree's prediction saturates near the largest value it has seen). Silicon (+1.34) is off for a related reason: as an elemental semiconductor it is a minority case with few similar neighbors. This is not a failure but a live demonstration of a core truth of machine learning: you cannot predict in regions your data does not cover.

6. Interpret Why It Worked (Gateway to the Discussion)

Finally, we see which of the six descriptors the model leaned on. A random forest exposes feature importances, letting us translate the basis of its predictions back into the language of materials science.

model_full = RandomForestRegressor(n_estimators=300, random_state=42)
model_full.fit(X, y)
imp = model_full.feature_importances_
order = np.argsort(imp)[::-1]
for j in order:
    print(f"{FEATURE_NAMES[j]:<12}{imp[j]:.3f}")

Output:

mean_mass   0.302
EN_diff     0.225
mean_Z      0.167
mean_period 0.165
mean_EN     0.100
mean_group  0.041

The top descriptors carry genuine physical meaning.

Confirming that the model predicts on grounds consistent with known physics, rather than by guesswork, is the first step of the final stage of the MI loop: interpretation.

7. Discussion: What Worked, the Limits, and What Comes Next

7.1 What worked

7.2 Limitations (this is the real point)

7.3 What comes next: series to go deeper

Each step you experienced here is covered in depth by its own introductory series. Continue from wherever your curiosity was sparked.

๐ŸŽฏ Review the whole thing as one script

Run the six cells top to bottom and they work as a single script to the end. "Run it all first, then read the internals" is a perfectly good way to proceed. Tweaking the numbers (add descriptors, add compounds, swap the model for linear regression) will deepen your feel for MI further. Well done getting here. You are now someone who has completed a full turn of the MI loop.

Disclaimer