Customer reviews contain a dense signal about product fit that most sizing systems ignore. When thousands of customers write phrases like “fits large,” “ordered my usual S but had to exchange for M,” or “the waist was too tight but the shoulders fit perfectly,” they’re collectively describing the gap between the brand’s size chart and actual garment dimensions.
Extracting this signal systematically and using it to calibrate size recommendations is one of the highest-value ML tasks in fashion e-commerce — and it’s more tractable than it sounds.
What fit reviews tell you
Fit-relevant text in reviews falls into several categories:
Size direction signals: “Runs small,” “fits true to size,” “runs large,” “size up,” “size down.” These are the most actionable signals — they directly indicate whether a garment fits as expected for a given size.
Dimension-specific signals: “Waist was too tight,” “sleeves too long,” “shoulders perfect but chest baggy.” These indicate which dimensions are problematic and in which direction.
Comparison signals: “I’m usually a 34C and ordered M — perfect fit,” “I’m 175cm and 70kg, size L is right.” These are gold: the reviewer is providing explicit body dimensions alongside a size verdict.
Context signals: “Great for a fitted look,” “runs big but I like the oversized style.” The same garment fitting “big” can be a problem or a feature depending on the customer’s intent.
Step 1: Size direction classification
The simplest useful extraction: does this review indicate the product runs small, true to size, or large?
from transformers import pipeline
import re
# Zero-shot classification using a pre-trained model
classifier = pipeline(
"zero-shot-classification",
model="facebook/bart-large-mnli"
)
FIT_LABELS = ["runs small", "true to size", "runs large"]
def classify_fit_direction(review_text: str) -> dict:
"""
Classify a review text as indicating whether the product runs
small, true to size, or large.
"""
# Pre-filter: check for fit-relevant content
fit_keywords = [
"size", "fit", "small", "large", "tight", "loose", "big", "narrow",
"wide", "shoulder", "waist", "chest", "length", "sleeve", "exchange",
"return", "sized up", "sized down", "true to"
]
text_lower = review_text.lower()
has_fit_content = any(kw in text_lower for kw in fit_keywords)
if not has_fit_content:
return {"fit_direction": "no_signal", "confidence": 0.0}
result = classifier(
review_text,
candidate_labels=FIT_LABELS,
hypothesis_template="This clothing {} ."
)
best_label = result["labels"][0]
best_score = result["scores"][0]
# Only trust high-confidence classifications
if best_score < 0.6:
return {"fit_direction": "unclear", "confidence": best_score}
return {
"fit_direction": best_label.replace(" ", "_"), # "runs_small" etc.
"confidence": round(best_score, 3),
"raw_text_excerpt": review_text[:100]
}
Step 2: Extracting reviewer body dimensions
The most valuable reviews include explicit body dimensions — height, weight, usual size. Extracting these creates a pseudo-dataset of (body_dimensions → purchased_size → satisfaction) triples.
import re
from typing import Optional
def extract_body_dimensions_from_review(review_text: str) -> dict:
"""
Extract body measurements mentioned in a review.
Returns dict of found measurements (not all will be present).
"""
extracted = {}
# Height patterns: "5'8", "175cm", "175 cm", "5 foot 8"
height_patterns = [
r"(\d)'(\d{1,2})\"?", # 5'8" or 5'8
r"(\d{3})\s*cm", # 175cm
r"(\d)\s+foot\s+(\d{1,2})", # 5 foot 8
]
for pattern in height_patterns:
match = re.search(pattern, review_text, re.IGNORECASE)
if match:
groups = match.groups()
if len(groups) == 2 and int(groups[0]) < 10:
# Feet and inches
feet, inches = int(groups[0]), int(groups[1])
extracted["height_cm"] = round((feet * 12 + inches) * 2.54, 1)
elif len(groups) == 1:
# Centimeters
height_val = int(groups[0])
if 140 <= height_val <= 220:
extracted["height_cm"] = float(height_val)
break
# Weight patterns: "140lbs", "65kg", "65 kg"
weight_patterns = [
r"(\d{2,3})\s*lbs?", # 140lbs
r"(\d{2,3})\s*kg", # 65kg
r"(\d{2,3})\s*pounds?", # 140 pounds
]
for pattern in weight_patterns:
match = re.search(pattern, review_text, re.IGNORECASE)
if match:
val = int(match.group(1))
if "lb" in pattern or "pound" in pattern:
extracted["weight_kg"] = round(val * 0.453592, 1)
else:
if 30 <= val <= 200:
extracted["weight_kg"] = float(val)
break
# Usual size: "usually wear a M", "normally a size 12", "I'm a medium"
size_patterns = [
r"(?:usually|normally|typically|always)\s+(?:wear|buy|get|order)\s+(?:a\s+)?([XxSsMmLl]+\d*)",
r"(?:I'?m?|I am)\s+(?:a\s+)?size\s+([XxSsMmLl]+\d*|\d+)",
r"my\s+usual\s+(?:size\s+)?(?:is\s+)?([XxSsMmLl]+\d*|\d+)",
]
for pattern in size_patterns:
match = re.search(pattern, review_text, re.IGNORECASE)
if match:
extracted["self_reported_size"] = match.group(1).upper()
break
return extracted
Step 3: Aggregating signals per product/size
With individual review signals extracted, aggregate them per product and size to detect systematic patterns.
from collections import defaultdict
import statistics
def aggregate_fit_signals(reviews: list[dict], product_id: str) -> dict:
"""
Aggregate fit signals across all reviews for a product.
reviews: list of dicts, each containing:
- purchased_size: str (the size the reviewer purchased)
- fit_direction: str ("runs_small", "true_to_size", "runs_large", "unclear")
- confidence: float
- height_cm: float (optional)
- weight_kg: float (optional)
"""
by_size = defaultdict(lambda: {"runs_small": 0, "true_to_size": 0, "runs_large": 0, "total": 0})
for review in reviews:
size = review.get("purchased_size")
direction = review.get("fit_direction")
confidence = review.get("confidence", 0.0)
if not size or direction in ("unclear", "no_signal") or confidence < 0.6:
continue
by_size[size][direction] += 1
by_size[size]["total"] += 1
results = {}
for size, counts in by_size.items():
total = counts["total"]
if total < 5: # Too few reviews for reliable signal
continue
pct_small = counts["runs_small"] / total
pct_tts = counts["true_to_size"] / total
pct_large = counts["runs_large"] / total
# Determine dominant signal
if pct_small > 0.5:
signal = "runs_small"
adjustment_sizes = +1 # Recommend one size up from standard recommendation
elif pct_large > 0.5:
signal = "runs_large"
adjustment_sizes = -1 # Recommend one size down
else:
signal = "true_to_size"
adjustment_sizes = 0
results[size] = {
"dominant_signal": signal,
"adjustment_sizes": adjustment_sizes,
"review_count": total,
"distribution": {
"runs_small_pct": round(pct_small * 100, 1),
"true_to_size_pct": round(pct_tts * 100, 1),
"runs_large_pct": round(pct_large * 100, 1)
}
}
return {"product_id": product_id, "fit_signals_by_size": results}
Step 4: Applying review signals to recommendations
Combine the prediction-API-based recommendation with the review-derived fit signal:
def recommend_with_review_calibration(
base_recommendation: str, # From body dimension prediction + size chart
size_order: list[str], # ["XS", "S", "M", "L", "XL"]
fit_signals: dict, # From aggregate_fit_signals()
purchased_size: str | None = None
) -> dict:
"""
Adjust a size recommendation based on crowd-sourced fit signals.
"""
if not fit_signals or not fit_signals.get("fit_signals_by_size"):
return {
"recommended_size": base_recommendation,
"review_calibration": "no_data"
}
signals_by_size = fit_signals["fit_signals_by_size"]
# Get the signal for the base recommended size
signal_for_rec = signals_by_size.get(base_recommendation, {})
adjustment = signal_for_rec.get("adjustment_sizes", 0)
if adjustment == 0:
return {
"recommended_size": base_recommendation,
"review_calibration": "confirmed",
"signal": signal_for_rec.get("dominant_signal", "true_to_size"),
"signal_review_count": signal_for_rec.get("review_count", 0)
}
# Apply adjustment
if base_recommendation in size_order:
base_idx = size_order.index(base_recommendation)
adjusted_idx = max(0, min(len(size_order) - 1, base_idx + adjustment))
adjusted_size = size_order[adjusted_idx]
else:
adjusted_size = base_recommendation
return {
"recommended_size": adjusted_size,
"base_recommendation": base_recommendation,
"review_calibration": "adjusted",
"adjustment_direction": "up" if adjustment > 0 else "down",
"signal": signal_for_rec.get("dominant_signal"),
"signal_review_count": signal_for_rec.get("review_count", 0),
"note": f"Reviews suggest this product runs {signal_for_rec.get('dominant_signal', '').replace('_', ' ')}."
}
The body-dimension signal from reviews
The most powerful signal is the subset of reviews where users provide explicit body dimensions alongside their size verdict. These let you validate or correct the body dimension → size mapping directly.
def build_validation_pairs(reviews: list[dict]) -> list[dict]:
"""
Extract (body_dimensions, purchased_size, satisfaction) triples
from reviews with explicit body measurements.
"""
pairs = []
for review in reviews:
has_dims = review.get("height_cm") and review.get("weight_kg")
has_size = review.get("purchased_size")
has_signal = review.get("fit_direction") in ("true_to_size",)
if has_dims and has_size:
pairs.append({
"height_cm": review["height_cm"],
"weight_kg": review["weight_kg"],
"purchased_size": review["purchased_size"],
"fit_outcome": review.get("fit_direction", "unknown"),
"satisfied": review.get("fit_direction") == "true_to_size"
})
return pairs
With a few hundred such pairs per product, you can validate whether the prediction API + size chart mapping is producing correct recommendations. If users who match size M predictions consistently report that M fits them, the mapping is good. If they consistently report needing L, your size chart data for that product is wrong.
Review-based calibration is a closed feedback loop: predictions improve as you accumulate purchase and return data. The body dimension prediction API provides the prior; customer reviews provide the update. The combination produces recommendations that are both theoretically grounded (based on anthropometric data) and empirically calibrated (adjusted for how specific products actually fit).