Learning Objectives
By the end of this hands-on, you will be able to do three things.
- Explain the core MI loop: describe, in your own words, the sequence data -> featurization (descriptors) -> training -> evaluation -> interpretation.
- Build descriptors from composition alone: parse formulas such as "GaAs" and "TiO2" and compute composition-weighted averages of element properties into features usable by machine learning (ML).
- Evaluate results honestly: compute MAE and R-squared with cross-validation, tell apart the compounds the model predicted well from the ones it missed, and reason about the misses in the language of materials science.
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
- Background: only basic Python (you can read variables, lists, functions, and for loops). No prior materials-science or machine-learning knowledge is required. Every technical term is defined on first use.
- What you need: Python 3 with the numerical library
numpyand the machine-learning libraryscikit-learn. Install them withpip install numpy scikit-learn. If you prefer to stay in the browser, it runs as-is on Google Colab. - How to proceed: the code is split into six cells. Copy and run them top to bottom; each cell builds on the previous one and carries you to the end. Every output shown is a genuine result produced by actually running the code.
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.
mean_EN: weighted average of electronegativity (how strongly an atom attracts electrons).EN_diff: the electronegativity difference among constituent elements (max minus min). It is a proxy for the ionicity of the bonding and is expected to relate closely to the band gap.mean_Z: weighted average atomic number.mean_group: weighted average group number.mean_period: weighted average period.mean_mass: weighted average atomic mass.
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.
- EN_diff (electronegativity difference, importance 0.23): the larger the electronegativity difference between constituent elements, the more ionic the bonding, and generally the wider the band gap. In the data too, high-EN_diff MgO and BN are wide-gap while low-EN_diff InSb and Ge are narrow-gap. The model discovered this ionicity-gap relationship on its own.
- mean_mass, mean_Z, mean_period (atomic mass, atomic number, period): these correlate strongly with one another and all capture the periodic trend that heavier elements (lower in the table) give smaller band gaps. Indeed the data contains series such as ZnS (3.60) -> ZnSe (2.70) -> ZnTe (2.25) and Si (1.12) -> Ge (0.67), where the gap shrinks as the anion gets heavier. The model learned this trend numerically.
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
- Using only formula strings as input, we completed one full turn of the MI loop: data -> featurization -> training -> evaluation -> interpretation.
- Compositionally similar compounds (GaAs, ZnO, GaN) were predicted to within about 0.5 eV, showing that even without external libraries the trend is learnable.
- From the feature importances we extracted grounds consistent with known physics: electronegativity difference as ionicity and constituent-element heaviness as a periodic trend.
7.2 Limitations (this is the real point)
- The dataset is too small: with 30 samples, a single outlier like MgO swings R-squared substantially. Real work uses hundreds to tens of thousands of samples.
- Composition alone ignores crystal structure: the same composition can have different band gaps depending on the crystal structure (polymorph). The TiO2 we included actually differs between anatase (about 3.2 eV) and rutile (about 3.0 eV), and SiC ranges from 2.4 to 3.3 eV across its stacking sequences (polytypes). Composition-based descriptors cannot, in principle, tell these apart. When structure matters you need crystal-graph or structural descriptors.
- Experimental and DFT values are not the same thing: textbook measured gaps and the density functional theory (DFT) gaps listed in databases like Materials Project differ systematically (standard DFT tends to underestimate the gap). Keep your data sources consistent when mixing them.
- No extrapolation: a random forest cannot predict outside the range of its training data (the MgO miss is the concrete example).
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.
- Introduction to Materials Informatics (MI): the big picture from the ground up, for those who want the full overview first.
- Introduction to Composition-Based Features: how to build the descriptors we hand-wrote here properly with matminer / Magpie. The direct next step for deepening featurization.
- Introduction to Materials Databases: the answer to the "too little data" problem, learning how to pull large amounts of real data from Materials Project.
- Introduction to Bayesian Optimization and Active Learning: the next step, using a predictive model to find promising materials with as few experiments as possible.
๐ฏ 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.