How to Fix Underfitting in Machine Learning: A Practical Guid
Underfitting = Underfitting means the model is not learning enough from the data.
How to Fix Underfitting in Machine Learning: A Practical Guid

Underfitting
Underfitting = Underfitting means the model is not learning enough from the data.
You fix it by giving the model more capacity, better information, or fewer constraints. Six levers to pull:
Underfitting happens when the model is too simple to capture the real relationship in the data.
It cannot learn the important patterns, so both training performance and validation performance are low.
To fix this, we need to make the model stronger, give it better features, train it longer, reduce unnecessary restrictions, or provide more useful training data.
1. Better features (highest impact)
Garbage in, garbage out even a complex model can’t learn what isn’t in the data.

Photo Better feature
- Create interaction features:
spend × frequency,days_since_last_purchase × basket_size - Add domain features: for Lazada fraud, things like
txn_velocity_1hr,avg_order_deviation,is_new_account - Polynomial features for non-linear relationships:
amount²,√recency - Aggregate features: rolling 7-day average spend, customer lifetime percentile
df['spend_x_freq'] = df['total_spend'] / (df['visit_freq'] + 1)
df['txn_velocity'] = df.groupby('customer_id')['txn_id'].transform(
lambda x: x.rolling('1H', on=df['timestamp']).count()
)
2. Use a more powerful model

Photo Model use case
Swap out the model class entirely when the current one can’t represent the pattern:

For tabular data at Lazada — jump straight to LightGBM. It handles non-linearity, interactions, and missing values natively.
import lightgbm as lgb
model = lgb.LGBMClassifier(
n_estimators=500,
max_depth=6,
num_leaves=63,
learning_rate=0.05
)
model.fit(X_train, y_train)
3. Reduce regularisation

Reduce regularisation
If you already applied regularisation, it may be too aggressive throttling the model’s ability to learn.
# Logistic regression — increase C (less penalty)
LogisticRegression(C=0.01) # too regularised → underfitting
LogisticRegression(C=1.0) # try this
LogisticRegression(C=10.0) # or this
# LightGBM — relax constraints
lgb.LGBMClassifier(
reg_alpha=0.0, # L1 — reduce from high value
reg_lambda=0.0, # L2 — reduce from high value
min_child_samples=5 # was 50 → too restrictive
)
4. Increase model complexity

For tree models — allow deeper trees and more leaves:
# Too shallow → underfitting
lgb.LGBMClassifier(max_depth=3, num_leaves=7)
# More capacity
lgb.LGBMClassifier(max_depth=8, num_leaves=127, n_estimators=1000)
For neural networks — add layers or neurons:
# Before (too simple)
model = Sequential([Dense(16, activation='relu'), Dense(1)])
# After (more capacity)
model = Sequential([
Dense(128, activation='relu'),
Dense(64, activation='relu'),
Dense(32, activation='relu'),
Dense(1, activation='sigmoid')
])
5. Train longer

Photo Longer Train
For gradient boosting — more rounds. For neural networks — more epochs. Underfitting often just means the model hasn’t converged yet.
A smaller learning rate paired with more estimators almost always beats a high learning rate with few trees.
6. Add more training data

Sometimes the model is fine but hasn’t seen enough examples to generalise. For fraud specifically — the minority class (actual fraud) is the bottleneck.
from imblearn.over_sampling import SMOTE
sm = SMOTE(sampling_strategy=0.2, random_state=42)
X_res, y_res = sm.fit_resample(X_train, y_train)
print(f"Before: {y_train.value_counts().to_dict()}")
print(f"After: {pd.Series(y_res).value_counts().to_dict()}")
Diagnosis checklist — how to confirm it’s underfitting
Before pulling any lever, verify you’re actually underfitting and not something else:
from sklearn.model_selection import learning_curve
import numpy as np
train_sizes, train_scores, val_scores = learning_curve(
model, X, y, cv=5, scoring='roc_auc',
train_sizes=np.linspace(0.1, 1.0, 10)
)
# Underfitting signature:
# - train_scores are LOW (e.g. 0.65–0.70)
# - val_scores are also LOW and close to train_scores
# - gap between them is small
print(f"Train AUC: {train_scores.mean(axis=1)[-1]:.3f}")
print(f"Val AUC: {val_scores.mean(axis=1)[-1]:.3f}")
If both are low and close → underfitting. If train is high but val is low → overfitting (different problem).
Priority order for fraud detection at Lazada
- Feature engineering first — domain knowledge beats model complexity
- Switch to LightGBM if not already using it
- Relax regularisation if you added it prematurely
- Increase depth / estimators
- Handle class imbalance (SMOTE or
scale_pos_weight) - More data only after the above are exhausted
Underfitting fixes — Lazada fraud detection
Full pipeline using realistic Lazada-style features. Copy and run on Databricks.
Viewed a file, created a file, read a file
Viewed a file, created a file, read a file
Here’s what the pipeline does end to end:
What each step does
Step 1 — Load from Delta Lake pulls from lazada.ma_prod_sales_ch joined with the org dimension (w_int_org_d) and a fraud label table, filtered to Thailand last 180 days.
Step 2 — Feature engineering (the biggest underfitting fix) builds lazada-specific signals using Spark window functions:
txn_count_7d/spend_7d— velocity features over rolling windowsspend_vs_avg_30d— how much this transaction deviates from the customer's own baseline (strong fraud signal)spend_x_txn_freq— interaction feature capturing high-value + high-frequency patterns
Step 3 — Time-based split uses 80% cutoff date as the boundary — not random split, which would leak future data into training.
Step 4 — SMOTE brings fraud up to 15% of training data so the model sees enough examples to learn the minority class pattern.
Step 5–6 — Baseline vs fixed model trains both and logs to MLflow under /lazada/fraud_detection/underfitting_fix. The baseline uses depth=2, 50 trees, heavy regularisation. The fixed model uses depth=7, 1000 trees, relaxed regularisation, smaller learning rate.
Step 7 — Learning curve is the diagnostic. If underfitting is resolved, train and val AUC should both converge above 0.90 with a small gap.
Step 8 — F2 threshold picks the operating point that weights recall 2× over precision — correct for fraud.
Step 9 — Write back to Delta saves scored predictions to lazada.ma_fraud_scores_th for downstream Power BI or alerting pipelines.
One thing to adjust before running
The label table lazada.ma_fraud_labels is assumed — replace with your actual confirmed fraud source, whether that's a chargeback table, a manual review outcome table, or the Locus logistics anomaly flags depending on which fraud type you're targeting.
my code :
# Databricks notebook source
# =============================================================================
# Lazada Fraud Detection — Underfitting Fix Pipeline
# Stack: Azure Databricks | Delta Lake | LightGBM | MLflow
# Layer: Lazada DWD / Gold layer
# =============================================================================
# COMMAND ----------
%pip install lightgbm imbalanced-learn "numpy>=2.0"
# COMMAND ----------
import mlflow
import mlflow.lightgbm
import lightgbm as lgb
import pandas as pd
import numpy as np
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
from pyspark.sql.window import Window
from sklearn.metrics import (
roc_auc_score,
classification_report,
precision_recall_curve,
average_precision_score
)
from imblearn.over_sampling import SMOTE
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")
spark = SparkSession.builder.getOrCreate()
# COMMAND ----------
# =============================================================================
# CONFIG — Lazada table and column mapping
# =============================================================================
CATALOG_NAME = "lazada"
SCHEMA_NAME = "dwd"
# Change this to your real Lazada DWD table
SOURCE_TABLE = "dwd_order_item_detail"
FULL_TABLE_NAME = f"{CATALOG_NAME}.{SCHEMA_NAME}.{SOURCE_TABLE}"
# Change this mapping based on real Lazada DWD columns
COL_MAP = {
"trx_id": "order_id",
"customer_id": "buyer_id",
"store_id": "seller_id",
"event_time": "order_created_at",
"day_key": "ds",
"sales_amount": "paid_amount",
"sales_qty": "quantity",
"profit_amount": "profit_amt",
"payment_channel": "payment_method",
"sales_channel": "platform",
"slab_discount": "platform_discount_amt",
"comp_discount": "seller_discount_amt",
"vat_rate": "tax_rate",
"cust_type_no": "buyer_type",
"class_no": "category_id"
}
START_DAY = "20260601"
SAMPLE_FRACTION_LEGIT = 0.01
FEATURE_PATH = "dbfs:/tmp/prem/lazada_fraud_features_sampled"
SCORE_PATH = "dbfs:/tmp/prem/lazada_fraud_scores_output"
MLFLOW_EXPERIMENT = "/Users/pvishnoi@lazada.com/ML_USE_CASE/lazada_lgbm_fraud_v1"
# COMMAND ----------
# =============================================================================
# STEP 1 — Load transactions from Lazada DWD table
# =============================================================================
spark.sql(f"USE CATALOG {CATALOG_NAME}")
raw_lazada = spark.sql(f"""
SELECT
{COL_MAP['trx_id']} AS trx_id,
{COL_MAP['customer_id']} AS customer_id,
{COL_MAP['store_id']} AS store_id,
{COL_MAP['event_time']} AS event_time,
{COL_MAP['day_key']} AS day_key,
{COL_MAP['sales_amount']} AS sales_amount,
{COL_MAP['sales_qty']} AS sales_qty,
{COL_MAP['profit_amount']} AS profit_amount,
{COL_MAP['payment_channel']} AS payment_channel,
{COL_MAP['sales_channel']} AS sales_channel,
{COL_MAP['slab_discount']} AS platform_discount,
{COL_MAP['comp_discount']} AS seller_discount,
{COL_MAP['vat_rate']} AS vat_rate,
{COL_MAP['cust_type_no']} AS customer_type,
{COL_MAP['class_no']} AS category_id
FROM {FULL_TABLE_NAME}
WHERE {COL_MAP['day_key']} >= '{START_DAY}'
AND {COL_MAP['sales_amount']} > 0
AND {COL_MAP['sales_qty']} > 0
""")
display(raw_lazada.limit(10))
# COMMAND ----------
# =============================================================================
# STEP 2 — Apply Lazada fraud rules
# =============================================================================
raw_labeled = raw_lazada.withColumn(
"is_fraud",
F.when(
(F.col("profit_amount") < 0) &
(F.col("sales_amount") > 5000),
1
).when(
(
F.coalesce(F.col("platform_discount"), F.lit(0)) +
F.coalesce(F.col("seller_discount"), F.lit(0))
) > (F.col("sales_amount") * 0.5),
1
).when(
(F.col("vat_rate") == 0) &
(F.col("sales_amount") > 10000),
1
).when(
(F.col("sales_qty") > 500) &
(F.col("sales_amount") / F.col("sales_qty") < 1.0),
1
).otherwise(0)
)
fraud_rows = raw_labeled.filter(F.col("is_fraud") == 1)
legit_rows = raw_labeled.filter(F.col("is_fraud") == 0) \
.sample(fraction=SAMPLE_FRACTION_LEGIT, seed=42)
df_sampled = fraud_rows.unionByName(legit_rows).cache()
count_total = df_sampled.count()
count_fraud = df_sampled.filter(F.col("is_fraud") == 1).count()
print(f"Sampled rows : {count_total:,}")
print(f"Fraud rows : {count_fraud:,} ({count_fraud / count_total:.2%})")
# COMMAND ----------
# =============================================================================
# STEP 3 — Feature engineering
# =============================================================================
window_customer = Window.partitionBy("customer_id") \
.orderBy(F.unix_timestamp("event_time")) \
.rangeBetween(-7 * 86400, 0)
window_store = Window.partitionBy("store_id") \
.orderBy(F.unix_timestamp("event_time")) \
.rangeBetween(-1 * 86400, 0)
df_feat = df_sampled \
.withColumn(
"basket_size",
F.col("sales_amount") / F.col("sales_qty").cast("double")
) \
.withColumn(
"margin_rate",
F.col("profit_amount") / F.col("sales_amount")
) \
.withColumn(
"total_discount",
F.coalesce(F.col("platform_discount"), F.lit(0)) +
F.coalesce(F.col("seller_discount"), F.lit(0))
) \
.withColumn(
"discount_rate",
F.col("total_discount") / F.col("sales_amount")
) \
.withColumn(
"txn_count_7d",
F.count("trx_id").over(window_customer)
) \
.withColumn(
"spend_7d",
F.sum("sales_amount").over(window_customer)
) \
.withColumn(
"avg_margin_7d",
F.avg("margin_rate").over(window_customer)
) \
.withColumn(
"store_txn_count_1d",
F.count("trx_id").over(window_store)
) \
.withColumn(
"hour_of_day",
F.hour("event_time")
) \
.withColumn(
"is_weekend",
F.dayofweek("event_time").isin([1, 7]).cast("int")
)
KEEP_COLS = [
"trx_id",
"customer_id",
"store_id",
"event_time",
"is_fraud",
"sales_amount",
"sales_qty",
"profit_amount",
"payment_channel",
"sales_channel",
"vat_rate",
"customer_type",
"category_id",
"basket_size",
"margin_rate",
"total_discount",
"discount_rate",
"txn_count_7d",
"spend_7d",
"avg_margin_7d",
"store_txn_count_1d",
"hour_of_day",
"is_weekend"
]
# COMMAND ----------
# =============================================================================
# STEP 4 — Write features to Delta, then read back to Pandas
# =============================================================================
df_feat.select(KEEP_COLS).write \
.format("delta") \
.mode("overwrite") \
.option("overwriteSchema", "true") \
.save(FEATURE_PATH)
print("Written feature table to DBFS.")
df = spark.read \
.format("delta") \
.load(FEATURE_PATH) \
.toPandas()
print(f"Shape : {df.shape}")
print(f"Memory : {df.memory_usage(deep=True).sum() / 1e6:.0f} MB")
print(f"Fraud : {df['is_fraud'].mean():.2%}")
# COMMAND ----------
# =============================================================================
# STEP 5 — Encode categorical columns and prepare train/validation data
# =============================================================================
cat_cols = ["payment_channel", "sales_channel"]
for col in cat_cols:
df[col] = df[col].astype(str).fillna("unknown")
df[col] = pd.factorize(df[col])[0]
FEATURE_COLS = [
"sales_amount",
"sales_qty",
"profit_amount",
"basket_size",
"margin_rate",
"total_discount",
"discount_rate",
"txn_count_7d",
"spend_7d",
"avg_margin_7d",
"store_txn_count_1d",
"hour_of_day",
"is_weekend",
"vat_rate",
"customer_type",
"category_id",
"payment_channel",
"sales_channel"
]
for col in FEATURE_COLS:
df[col] = pd.to_numeric(df[col], errors="coerce")
df[FEATURE_COLS] = df[FEATURE_COLS].fillna(0)
df["event_time"] = pd.to_datetime(df["event_time"], errors="coerce")
df = df.sort_values("event_time").reset_index(drop=True)
cutoff = df["event_time"].quantile(0.8)
train_df = df[df["event_time"] <= cutoff]
val_df = df[df["event_time"] > cutoff]
X_train = train_df[FEATURE_COLS]
y_train = train_df["is_fraud"]
X_val = val_df[FEATURE_COLS]
y_val = val_df["is_fraud"]
print(f"Train : {len(X_train):,} rows | fraud rate: {y_train.mean():.2%}")
print(f"Val : {len(X_val):,} rows | fraud rate: {y_val.mean():.2%}")
# COMMAND ----------
# =============================================================================
# STEP 6 — Apply SMOTE only if fraud rate is too low
# =============================================================================
if y_train.mean() < 0.10:
sm = SMOTE(
sampling_strategy=0.15,
k_neighbors=5,
random_state=42
)
X_train_bal, y_train_bal = sm.fit_resample(X_train, y_train)
print(f"SMOTE applied — new fraud rate: {y_train_bal.mean():.2%}")
else:
X_train_bal, y_train_bal = X_train, y_train
print(f"Fraud rate {y_train.mean():.2%} — SMOTE skipped")
# COMMAND ----------
# =============================================================================
# STEP 7 — Clean numeric data before training
# =============================================================================
X_train_bal = X_train_bal.apply(pd.to_numeric, errors="coerce").fillna(0).astype("float64")
X_val = X_val.apply(pd.to_numeric, errors="coerce").fillna(0).astype("float64")
bad_cols = X_train_bal.select_dtypes(exclude=["int", "float", "bool"]).columns.tolist()
print(f"Bad cols remaining: {bad_cols if bad_cols else 'None ✅'}")
print(X_train_bal.dtypes)
# COMMAND ----------
# =============================================================================
# STEP 8 — Train LightGBM model and log to MLflow
# =============================================================================
mlflow.set_experiment(MLFLOW_EXPERIMENT)
params = {
"objective": "binary",
"metric": "auc",
# More model capacity to reduce underfitting
"n_estimators": 1000,
"max_depth": 7,
"num_leaves": 63,
"learning_rate": 0.03,
# Regularization
"reg_alpha": 0.1,
"reg_lambda": 0.5,
"min_child_samples": 20,
# Sampling
"feature_fraction": 0.8,
"bagging_fraction": 0.8,
"bagging_freq": 5,
# Class imbalance handling
"scale_pos_weight": (y_train_bal == 0).sum() / max((y_train_bal == 1).sum(), 1),
"verbose": -1
}
with mlflow.start_run(run_name="lgbm_lazada_fraud_v1"):
mlflow.log_params(params)
model = lgb.LGBMClassifier(**params)
model.fit(
X_train_bal,
y_train_bal,
eval_set=[(X_val, y_val)],
callbacks=[
lgb.early_stopping(stopping_rounds=50, verbose=False),
lgb.log_evaluation(period=100)
]
)
val_proba = model.predict_proba(X_val)[:, 1]
val_auc = roc_auc_score(y_val, val_proba)
val_prauc = average_precision_score(y_val, val_proba)
mlflow.log_metrics({
"val_auc": round(val_auc, 4),
"val_prauc": round(val_prauc, 4)
})
mlflow.lightgbm.log_model(model, artifact_path="model")
print(f"Val AUC : {val_auc:.4f}")
print(f"Val PR-AUC: {val_prauc:.4f}")
# COMMAND ----------
# =============================================================================
# STEP 9 — Find best fraud threshold using F2 score
# =============================================================================
precision, recall, thresholds = precision_recall_curve(y_val, val_proba)
f2 = (5 * precision * recall) / (4 * precision + recall + 1e-9)
best_idx = np.argmax(f2)
best_thresh = thresholds[min(best_idx, len(thresholds) - 1)]
y_pred = (val_proba >= best_thresh).astype(int)
print(f"Threshold : {best_thresh:.3f}")
print(f"F2 score : {f2[best_idx]:.4f}")
print()
print(classification_report(y_val, y_pred, target_names=["Legit", "Fraud"]))
# COMMAND ----------
# =============================================================================
# STEP 10 — Feature importance
# =============================================================================
feat_imp = pd.DataFrame({
"feature": FEATURE_COLS,
"importance": model.feature_importances_
}).sort_values("importance", ascending=False)
print(feat_imp.to_string(index=False))
feat_imp.head(10).sort_values("importance").plot(
kind="barh",
x="feature",
y="importance",
figsize=(8, 5),
legend=False,
title="Top 10 features — Lazada fraud model"
)
plt.tight_layout()
plt.show()
# COMMAND ----------
# =============================================================================
# STEP 11 — Write fraud scores to Delta
# =============================================================================
scores_df = val_df[
[
"trx_id",
"customer_id",
"store_id",
"event_time",
"sales_amount",
"is_fraud"
]
].copy()
scores_df["fraud_score"] = val_proba
scores_df["fraud_flag"] = y_pred
scores_df["model_version"] = "lgbm_lazada_v1"
scores_df["scored_at"] = pd.Timestamp.now()
spark.createDataFrame(scores_df).write \
.format("delta") \
.mode("overwrite") \
.save(SCORE_PATH)
print("✅ Scores written")
print(f"Total scored : {len(scores_df):,}")
print(f"Fraud flagged : {y_pred.sum():,} ({y_pred.mean():.2%})")
# COMMAND ----------
# =============================================================================
# STEP 12 — Diagnostic: check which fraud rules are triggering most
# =============================================================================
df_diag = val_df.copy()
df_diag["rule1_neg_margin"] = (
(pd.to_numeric(df_diag["profit_amount"], errors="coerce") < 0) &
(pd.to_numeric(df_diag["sales_amount"], errors="coerce") > 5000)
).astype(int)
df_diag["rule2_discount_abuse"] = (
pd.to_numeric(df_diag["total_discount"], errors="coerce") >
pd.to_numeric(df_diag["sales_amount"], errors="coerce") * 0.5
).astype(int)
df_diag["rule3_zero_vat"] = (
(pd.to_numeric(df_diag["vat_rate"], errors="coerce") == 0) &
(pd.to_numeric(df_diag["sales_amount"], errors="coerce") > 10000)
).astype(int)
df_diag["rule4_qty_value"] = (
(pd.to_numeric(df_diag["sales_qty"], errors="coerce") > 500) &
(
pd.to_numeric(df_diag["sales_amount"], errors="coerce") /
pd.to_numeric(df_diag["sales_qty"], errors="coerce").clip(lower=1)
< 1.0
)
).astype(int)
total = len(df_diag)
print(f"Total validation transactions : {total:,}")
print()
print(f"Rule 1 — negative margin > 5000 : {df_diag['rule1_neg_margin'].sum():,} ({df_diag['rule1_neg_margin'].mean():.2%})")
print(f"Rule 2 — discount > 50% of amount : {df_diag['rule2_discount_abuse'].sum():,} ({df_diag['rule2_discount_abuse'].mean():.2%})")
print(f"Rule 3 — zero VAT > 10000 : {df_diag['rule3_zero_vat'].sum():,} ({df_diag['rule3_zero_vat'].mean():.2%})")
print(f"Rule 4 — high qty, low unit value : {df_diag['rule4_qty_value'].sum():,} ({df_diag['rule4_qty_value'].mean():.2%})")
print()
print(f"Any rule fires — is_fraud = 1 : {df_diag['is_fraud'].sum():,} ({df_diag['is_fraud'].mean():.2%})")
# COMMAND ----------
# =============================================================================
# STEP 13 — Distribution checks
# =============================================================================
amt = pd.to_numeric(val_df["sales_amount"], errors="coerce")
print("sales_amount distribution:")
print(f"min : {amt.min():.2f}")
print(f"median : {amt.median():.2f}")
print(f"mean : {amt.mean():.2f}")
print(f"p75 : {amt.quantile(0.75):.2f}")
print(f"p90 : {amt.quantile(0.90):.2f}")
print(f"p95 : {amt.quantile(0.95):.2f}")
print(f"p99 : {amt.quantile(0.99):.2f}")
print(f"max : {amt.max():.2f}")
print()
profit = pd.to_numeric(val_df["profit_amount"], errors="coerce")
print(f"Negative profit_amount : {(profit < 0).sum():,} ({(profit < 0).mean():.2%})")
print(f"Zero profit_amount : {(profit == 0).sum():,} ({(profit == 0).mean():.2%})")
print()
vat = pd.to_numeric(val_df["vat_rate"], errors="coerce")
print(f"Zero vat_rate : {(vat == 0).sum():,} ({(vat == 0).mean():.2%})")
print(f"vat_rate unique values : {sorted(vat.dropna().unique().tolist())}")
print()
disc = pd.to_numeric(val_df["total_discount"], errors="coerce")
print(f"discount > 50% of amt : {(disc > amt * 0.5).sum():,} ({(disc > amt * 0.5).mean():.2%})")
# COMMAND ----------
# =============================================================================
# STEP 14 — Check output files
# =============================================================================
display(dbutils.fs.ls("dbfs:/tmp/prem/"))
score_files = dbutils.fs.ls(SCORE_PATH)
score_size_mb = sum(f.size for f in score_files) / 1e6
print(f"Score files : {len(score_files)}")
print(f"Score size : {score_size_mb:.1f} MB")
# COMMAND ----------
# =============================================================================
# STEP 15 — Clean temporary feature files
# Keep fraud scores, remove only temporary feature data
# =============================================================================
dbutils.fs.rm(FEATURE_PATH, recurse=True)
print("✅ Temp feature files cleaned")
print(f"✅ Final fraud scores are available at: {SCORE_PATH}") 메타데이터
- post_id
- 03dfe6cbfffe
- slug
- how-to-fix-underfitting-in-machine-learning-a-practical-guid-03dfe6cbfffe
- url
- https://medium.com/nextgenllm/how-to-fix-underfitting-in-machine-learning-a-practical-guid-03dfe6cbfffe
- canonical_url
- https://medium.com/nextgenllm/how-to-fix-underfitting-in-machine-learning-a-practical-guid-03dfe6cbfffe
- author_url
- https://medium.com/@premvishnoi
- status
- ok
- fetched_at
- 2026-06-10 22:22:12