9,000 products, no attributes, and every filter on the site returning nothing. Tag the catalogue before you read the walkthrough.
The situation
Marloe Finch merged three suppliers and inherited their catalogues. 9,000 products across 20 product types, each with a title and a supplier description and nothing else. Filters are empty and search only finds what is literally in the title.
Ecommerce wants the filters working, and wants to know which ones can be trusted.
The data
| File | What it holds |
|---|---|
| marloe-finch-catalogue.csv | sku, title, description, product_type, price |
| marloe-finch-attributes.csv | The six true attributes for every product: colour, size, material, room, care, style. Do not open it until you have a tagger |
What the room believes
- One model can tag the whole catalogue.
- A dictionary lookup is a reasonable substitute for a model.
- Overall accuracy tells you whether the tagging is good enough.
- Attributes the text does not state cannot be predicted.
Definition of done
- A verdict on each of the four beliefs, with the evidence.
- A tagger compared against a dictionary lookup and against a trivial baseline, reported per attribute rather than as one number.
- A measurement of how often each attribute is actually stated in the text, and what that predicts.
- A rule for when a tag is confident enough to apply, and a defence of it.
- A decision per attribute: which filters go live, which go live with gaps, and which should not be built.
Five questions worth sitting with before you build anything
How much of each attribute is written down, and how would you check? What can a model know that a dictionary cannot? How many products does a filter need behind it before it is worth offering? Should every attribute share one confidence threshold? And is there an attribute here that no amount of modelling will recover?
If you want to go further
- Measure, for each attribute, how often its value literally appears in the text, then compare that to how well you predict it.
- Build the dictionary lookup first and see which attributes your model actually improves.
- Count filter values with enough products behind them, rather than reporting accuracy.
- Give each attribute its own threshold and see how differently they land.
- Find the attribute where the model barely beats random, and work out why.
When you are done, read the walkthrough. It reports every attribute separately, measures what the model adds over a dictionary on each, and reaches a different decision for four of the six. Compare its per attribute decisions to yours.
9,000 products with no attributes, so every filter on the site is empty. One model tags all six attributes and reports 67.9 macro F1, which is an average across a 65.3 point spread. One of the six should not be shipped at all.
The situation. Marloe Finch merged three suppliers and the catalogue came with them. 9,000 products, 20 product types, a title and a supplier description each, and no structured attributes. Filters return nothing and search only works if you guess the words in the title.
What the business is left with. A tagged catalogue, a tagging service for new products, and a per attribute decision about which filters go live and which do not.
Attempt it first. The brief has the same catalogue and the attributes to score against.
Contents
Section 1Problem Definition
No code yet. This gets scoped as one tagging model and it is six different problems wearing the same hat.
The problem in one sentence
An attribute can only be predicted from the text if the text contains it or implies it, and the six attributes here differ enormously in whether they do.
| Where an attribute comes from | Example here | What that means for tagging |
|---|---|---|
| Written in the title almost every time | colour, stated in 88.6% of listings | A dictionary lookup gets most of it. A model gets slightly more |
| Written in the description sometimes | material, stated in 60.6% | The model has to fill the gaps from context |
| Rarely written, but implied by the product | room, stated in 18.4% and inferable from what the thing is | The model beats the dictionary by a wide margin because it can infer |
| A judgement nobody records | style, stated in 5.5% | There is nothing to learn from. No model fixes this |
Business objective
Fill the filters. That means tagging enough products per filter value, accurately enough that the filter is not lying, and knowing which filters cannot be filled from the text at all.
Hypotheses
- H1. One model can tag the whole catalogue.
- H2. A dictionary lookup is a reasonable substitute for a model.
- H3. Overall accuracy tells you whether the tagging is good enough.
- H4. Attributes the text does not state cannot be predicted.
What a filter needs, which is not what a metric measures
A colour filter is useful when each colour has enough products behind it to be worth clicking. Fewer than 25 and the customer lands on a near empty page, which is worse than not offering the filter. So every result below is reported twice: as a model score, and as the number of filter values that end up with stock behind them.
Section 2Data Collection
import numpy as np
import pandas as pd
c = pd.read_csv('data/marloe-finch-catalogue.csv')
a = pd.read_csv('data/marloe-finch-attributes.csv')
d = c.merge(a, on='sku')
d['text'] = (d['title'] + '. ' + d['description']).str.lower()
ATTRS = ['colour', 'size', 'material', 'room', 'care', 'style']
print('products :', len(d))
print('product types :', d['product_type'].nunique())
print('title words :', round(d['title'].str.split().str.len().mean(), 1))
print('desc words :', round(d['description'].str.split().str.len().mean(), 1))
print('price range :', round(d['price'].min(), 2), 'to', round(d['price'].max(), 2))
products : 9000
product types : 20
title words : 4.1
desc words : 12.6
price range : 8.01 to 319.94
Four words of title and thirteen of description. That is all there is. Any attribute not carried by seventeen words is not going to be recovered from them, and section 4d measures which ones are.
Section 3Data Preprocessing
3aDuplicates and schema checks
print('duplicate skus :', int(d['sku'].duplicated().sum()))
print('missing titles :', int(d['title'].isna().sum()))
print('missing descriptions :', int(d['description'].isna().sum()))
print('products without every attribute :', int(d[ATTRS].isna().any(axis=1).sum()))
duplicate skus : 0
missing titles : 0
missing descriptions : 0
products without every attribute : 0
3bHandling categorical mess
The product type is the one structured field that survived the merge, and it turns out to carry most of what the model knows about room and care. That is worth noticing early: some of the attributes are not being read out of the text at all, they are being inferred from what the product is.
top = d['product_type'].value_counts()
print('product types :', len(top))
print('commonest :', top.index[0], round(100 * top.iloc[0] / len(d), 1), 'percent')
print('rarest :', top.index[-1], round(100 * top.iloc[-1] / len(d), 1), 'percent')
print()
print('how much the type alone tells you about each attribute:')
for at in ATTRS:
g = d.groupby('product_type')[at].agg(lambda s: s.value_counts().iloc[0] / len(s))
print(' %-9s best single guess per type averages %5.1f percent'
% (at, 100 * g.mean()))
product types : 20
commonest : bath towel 5.5 percent
rarest : bookshelf 4.4 percent
how much the type alone tells you about each attribute:
colour best single guess per type averages 10.5 percent
size best single guess per type averages 27.3 percent
material best single guess per type averages 43.4 percent
room best single guess per type averages 68.1 percent
care best single guess per type averages 71.2 percent
style best single guess per type averages 22.2 percent
The product type alone pins down care and room far better than it pins down colour, which is the first sign that these six attributes are not one problem. A cushion cover comes in any colour and is always for the living room or the bedroom.
3cDealing with outliers
Price has a long tail and is not used. It correlates with material and would leak a little signal that a new product from a new supplier will not carry in the same way.
3dHandling missing values
Nothing is missing in the file. What is missing is in the text: the attribute the supplier did not write down. That is the entire problem and it is not an imputation task.
3eHandling skewed data
Attribute values are reasonably balanced, which matters because it makes macro F1 readable and it makes a random guess a meaningful floor. Guessing at random scores between 5.8 and 24.1 depending on the attribute, and one of the six barely clears that.
3fData types and normalisation
Title and description are concatenated and lowercased for the dictionary lookup, and passed to the encoder as written. The encoder was pretrained on ordinary prose and normalising it away costs accuracy.
Section 4Exploratory Data Analysis
4aTarget variable analysis
for at in ATTRS:
v = d[at].value_counts()
print('%-9s %2d values commonest %-16s %5.1f percent'
% (at, len(v), v.index[0], 100 * v.iloc[0] / len(d)))
colour 12 values commonest blush 8.8 percent
size 4 values commonest small 25.6 percent
material 13 values commonest cotton 21.9 percent
room 6 values commonest living room 29.2 percent
care 5 values commonest not applicable 59.1 percent
style 5 values commonest scandinavian 20.7 percent
# Are the attributes independent, or is one a proxy for another?
import itertools
for x, y in itertools.combinations(ATTRS, 2):
ct = pd.crosstab(d[x], d[y], normalize='index')
best = ct.max(axis=1).mean()
if best > 0.5:
print('%-9s predicts %-9s at %5.1f percent' % (x, y, 100 * best))
print('pairs above 50 percent listed above, all others are below')
colour predicts care at 59.1 percent
size predicts care at 59.1 percent
material predicts room at 56.8 percent
material predicts care at 72.2 percent
room predicts care at 70.2 percent
pairs above 50 percent listed above, all others are below
4bNumerical variables
Only price, and it is deliberately excluded. The whole signal here is text.
4cCategorical variables
Six attributes with between four and thirteen values each. Treated as six independent single label problems rather than one large one, because the answer turns out to be different for each.
4dRelationships between variables
The measurement that predicts everything else: how often does the attribute value actually appear in the text.
SIZE_WORDS = {'small': ['small', '40cm', ' s '], 'medium': ['medium', '60cm', ' m '],
'large': ['large', '90cm', ' l '],
'extra large': ['extra large', '120cm', ' xl ']}
for at in ATTRS:
if at == 'size':
stated = np.mean([any(w in ' ' + t + ' ' for w in SIZE_WORDS[v])
for t, v in zip(d['text'], d[at])])
else:
stated = np.mean([v.lower() in t for t, v in zip(d['text'], d[at])])
print('%-9s stated in the text %5.1f percent of the time' % (at, 100 * stated))
colour stated in the text 88.6 percent of the time
size stated in the text 48.7 percent of the time
material stated in the text 60.6 percent of the time
room stated in the text 18.4 percent of the time
care stated in the text 14.3 percent of the time
style stated in the text 5.5 percent of the time
Colour is written down 88.6% of the time and style 5.5%. Anything a dictionary can do is bounded by this chart. What a model adds is everything it can infer from the rest of the sentence, and section 7b measures exactly how much that is worth per attribute.
4eTesting our hypotheses
| Hypothesis | Verdict | Evidence |
|---|---|---|
| H1. One model can tag the whole catalogue | It can run, it should not ship | The same model reaches 89.9 on colour and 24.6 on style. The average of 67.9 describes neither |
| H2. A dictionary is a reasonable substitute | Only where the text states it | The model wins on all six, by 3.2 points on colour and 44.7 on room |
| H3. Overall accuracy tells you if it is good enough | No | At 0.9 confidence, colour fills 12 of 12 filters and style fills 0 |
| H4. Unstated attributes cannot be predicted | Wrong, with one exception | Room is stated 18.4% of the time and reaches 68.9 F1, because a bath mat is for the bathroom |
4fSubgroups
The subgroup is the attribute, and treating the six as one problem is the mistake this project exists to correct.
Section 5Feature Engineering
5aThe leakage trap
| The trap | Why it is tempting | What it does |
|---|---|---|
| Using price as a feature | It is numeric and it correlates with material | A new supplier prices differently and the tagger degrades quietly |
| Using product type as a feature | It is structured and it is genuinely predictive | Legitimate here, and worth being explicit about: much of the room and care performance is the type, not the text |
| Scoring on the products the dictionary already tagged | It makes the numbers look excellent | It measures the easy subset. Every score below is on the whole test set |
5bNew features
| Representation | What it can do | What it cannot |
|---|---|---|
| Dictionary lookup on the text | Find a value that is written down | Infer anything. Its ceiling is the chart in 4d |
| Sentence embedding of title plus description | Find written values, and infer unwritten ones from the rest of the sentence | Invent information that is not implied anywhere |
5cEncoding
One classifier per attribute on a shared 384 dimension embedding. Six small models on one encode pass, which costs almost nothing and lets each attribute have its own threshold. A single multi-output model would force one threshold on all six, and section 7c is about why that would be wrong.
5dFeature selection
Nothing selected. The decision worth making is which attributes to ship, not which features to keep.
Section 6Model Selection
| Method | What it is | Why it is here |
|---|---|---|
| Commonest value | Tag everything with the most frequent value | The floor. On a balanced attribute it scores close to nothing on macro F1 |
| Dictionary lookup | Match the value in the text, longest first | What most teams build first, and what the model has to beat |
| Embeddings plus a classifier per attribute | One logistic regression per attribute on a frozen sentence embedding | Cheap, and it can infer what the text implies rather than states |
Why not fine tune here
DL-1 in this series found that fine tuning beat frozen embeddings on support tickets, because the ordering within a ticket mattered. A product title is four words with no meaningful order to learn, and the attribute is either in those words or implied by the product type. There is nothing for the extra capacity to do, and the frozen encoder is one pass instead of twenty minutes.
Section 7Model Training
7aBaselines
A 2700 product test set, 6300 for training, one classifier per attribute.
from sentence_transformers import SentenceTransformer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score
from sklearn.model_selection import train_test_split
E = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2', device='cpu').encode(
(d['title'] + '. ' + d['description']).tolist(), batch_size=128,
normalize_embeddings=True)
tr, te = train_test_split(np.arange(len(d)), test_size=0.30, random_state=1)
print('train', len(tr), ' test', len(te), ' embedding', E.shape)
def dictionary_tag(at):
# Does the value literally appear. The rule anyone writes first.
vals = sorted(d[at].unique())
out = []
for t in d['text']:
pad, hit = ' ' + t + ' ', ''
if at == 'size':
for v in vals:
if v in SIZE_WORDS and any(w in pad for w in SIZE_WORDS[v]):
hit = v
break
else:
for v in sorted(vals, key=len, reverse=True):
if v.lower() in t:
hit = v
break
out.append(hit)
return np.array(out, dtype=object)
PROB = {}
print('%-9s %8s %11s %11s %10s'
% ('attribute', 'stated', 'commonest', 'dictionary', 'model'))
for at in ATTRS:
y = d[at].values
dt = dictionary_tag(at)
maj = pd.Series(y[tr]).mode()[0]
clf = LogisticRegression(max_iter=3000, C=8.0).fit(E[tr], y[tr])
PROB[at] = clf.predict_proba(E[te])
f = lambda p: 100 * f1_score(y[te], p, average='macro', zero_division=0)
print('%-9s %19.1f %11.1f %10.1f'
% (at, f([maj] * len(te)), f(dt[te]), f(clf.predict(E[te]))))
attribute stated commonest dictionary model
colour 88.6% 1.2 86.7 89.9
size 48.7% 10.1 52.3 79.0
material 60.6% 2.7 70.8 77.2
room 18.4% 7.7 24.2 68.9
care 14.3% 14.9 34.8 67.9
style 5.5% 7.0 8.9 24.6
7bComparing candidates
The model wins on every attribute, and by wildly different margins. On colour it is worth 3.2 points, because the text already says the colour. On room it is worth 44.7, because the text almost never says the room and the model infers it from the product.
The gain is smallest on colour, which the text already states 88.6% of the time, and largest on room and care, which it almost never states. That is the shape of a model doing inference rather than pattern matching, and it is the argument for paying for one.
The model earns its place by inferring, not by reading
Room is stated in 18.4% of listings and the model reaches 68.9 macro F1, which is 50.5 points above what is written down. A bath mat is for the bathroom whether or not anybody typed it.
Across the six attributes, how often the value is stated correlates with model F1 at 0.776. Strong, and not one. The residual is exactly the inference, and it is where the model is worth paying for.
Style is the exception that decides the handoff. It reaches 24.6 macro F1 against 21.3 for guessing at random, a margin of 3.3 points. Style is a merchandising judgement about a product, not a property of it, and seventeen words of supplier copy do not contain it.
7cHyperparameter tuning
One dial per attribute: the confidence above which a tag is applied. Raising it trades how much of the catalogue gets tagged against how often the tag is wrong.
MIN_PER_FILTER = 25
print('%-9s %10s %10s %10s %14s'
% ('attribute', 'threshold', 'tagged', 'accuracy', 'usable filters'))
for at in ATTRS:
y, pr = d[at].values[te], PROB[at]
classes = sorted(d[at].unique())
pred = np.array(classes)[pr.argmax(1)]
conf = pr.max(1)
for thr in (0.0, 0.5, 0.7, 0.9):
keep = conf >= thr
usable = sum(1 for v in np.unique(pred[keep])
if (pred[keep] == v).sum() >= MIN_PER_FILTER)
print('%-9s %10.1f %9.1f%% %9.1f %10d of %d'
% (at, thr, 100 * keep.mean(), 100 * (pred[keep] == y[keep]).mean(),
usable, d[at].nunique()))
attribute threshold tagged accuracy usable filters
colour 0.0 100.0% 89.9 12 of 12
colour 0.5 88.6% 100.0 12 of 12
colour 0.7 87.1% 100.0 12 of 12
colour 0.9 76.1% 100.0 12 of 12
size 0.0 100.0% 79.0 4 of 4
size 0.5 74.9% 96.9 4 of 4
size 0.7 69.3% 99.9 4 of 4
size 0.9 51.3% 100.0 4 of 4
material 0.0 100.0% 78.0 13 of 13
material 0.5 82.6% 86.7 13 of 13
material 0.7 64.8% 97.6 12 of 13
material 0.9 53.3% 99.9 10 of 13
room 0.0 100.0% 72.7 6 of 6
room 0.5 86.4% 78.4 6 of 6
room 0.7 54.4% 96.2 5 of 6
room 0.9 45.2% 99.9 5 of 6
care 0.0 100.0% 83.7 5 of 5
care 0.5 78.8% 99.0 5 of 5
care 0.7 77.3% 100.0 5 of 5
care 0.9 71.2% 100.0 5 of 5
style 0.0 100.0% 24.6 5 of 5
style 0.5 3.0% 96.3 0 of 5
style 0.7 1.1% 100.0 0 of 5
style 0.9 0.1% 100.0 0 of 5
At 0.9 confidence every attribute that gets tagged is tagged almost perfectly. The difference is how much of the catalogue clears the bar. Colour tags 76.1% and fills all 12 filters. Style tags 0.1% and fills none.
7dFinal evaluation
# What a merchandiser actually receives, per attribute, at 0.9 confidence.
for at in ATTRS:
pr = PROB[at]
classes = sorted(d[at].unique())
pred, conf = np.array(classes)[pr.argmax(1)], pr.max(1)
keep = conf >= 0.9
usable = sum(1 for v in np.unique(pred[keep])
if (pred[keep] == v).sum() >= MIN_PER_FILTER)
verdict = ('ship' if usable == d[at].nunique()
else 'do not ship' if usable < d[at].nunique() / 2
else 'ship with gaps flagged')
print('%-9s tagged %5.1f%% filters %2d of %2d %s'
% (at, 100 * keep.mean(), usable, d[at].nunique(), verdict))
colour tagged 76.1% filters 12 of 12 ship
size tagged 51.3% filters 4 of 4 ship
material tagged 53.3% filters 10 of 13 ship with gaps flagged
room tagged 45.2% filters 5 of 6 ship with gaps flagged
care tagged 71.2% filters 5 of 5 ship
style tagged 0.1% filters 0 of 5 do not ship
The deliverable is not a model score, it is a decision per attribute.
| Attribute | Stated | Model F1 | Tagged at 0.9 | Filters filled | Decision |
|---|---|---|---|---|---|
| colour | 88.6% | 89.9 | 76.1% | 12 of 12 | ship |
| size | 48.7% | 79.0 | 51.3% | 4 of 4 | ship |
| material | 60.6% | 77.2 | 53.3% | 10 of 13 | ship, and ask suppliers for the rest |
| room | 18.4% | 68.9 | 45.2% | 5 of 6 | ship, and ask suppliers for the rest |
| care | 14.3% | 67.9 | 71.2% | 5 of 5 | ship |
| style | 5.5% | 24.6 | 0.1% | 0 of 5 | do not ship |
Ship four filters, patch two, and take one to the suppliers
colour, size, care fill every filter value they have at 0.9 confidence and go live as they are. material and room fill most of theirs and should go live with the gaps flagged for suppliers to complete.
Style fills 0 filter values, because it is not in the text and cannot be inferred from it. The recommendation is not a better model. It is a required field in the supplier onboarding form.
Section 8Documentation and Handoff
What to do, and who owns it
| Action | Detail | Owner |
|---|---|---|
| Tag per attribute, not per catalogue | Six classifiers on one encode pass, each with its own confidence threshold. The attributes need different thresholds because they are different problems | Analytics |
| Ship the filters that fill | colour, size, care fill every value they have | Ecommerce |
| Flag the gaps rather than guessing them | material and room leave some values short of 25 products. Show the filter, list the untagged products for a human | Ecommerce |
| Make style a required supplier field | It is stated in 5.5% of listings and no amount of modelling recovers it | Buying |
| Report per attribute, never an average | The mean of 67.9 here spans 24.6 to 89.9 | Analytics |
| Re-tag when a new supplier lands | A supplier with a different house style for titles is the thing most likely to break this quietly | Analytics |
What not to do
- Do not report one number for a multi attribute tagger. The average here is 67.9 and the range is 65.3 points.
- Do not assume the dictionary is enough. The model beat it on all six attributes, by 44.7 points where inference was possible.
- Do not assume the dictionary is useless either. On colour it is within 3.2 points and needs no encoder.
- Do not ship a filter with three products behind it. An empty results page is worse than no filter.
- Do not use one confidence threshold across attributes. The same threshold tags 76.1% of colours and 0.1% of styles.
- Do not fine tune before checking there is signal to find. On style there is nothing in the text to fit.
Reproducibility
| Item | Value |
|---|---|
| Files | marloe-finch-catalogue.csv, marloe-finch-attributes.csv |
| Catalogue | 9,000 products, 20 product types, six attributes |
| Split | 6300 train, 2700 test, seed 1 |
| Encoder | sentence-transformers/all-MiniLM-L6-v2, frozen, 384 dimensions |
| Classifier | One logistic regression per attribute, C = 8 |
| Filter rule | A filter value is usable at 25 or more tagged products, measured at 0.9 confidence |
| Ground truth | All six attributes per product, from the generator |
| Libraries | pandas, numpy, scikit-learn, sentence-transformers |
What to take from this
- An attribute is only predictable if the text states or implies it. Measure that before you build anything.
- A model earns its money on inference, not on reading. The biggest wins were on the attributes the text almost never states.
- Averages across attributes are meaningless. Six problems, one number, 65.3 points of spread.
- Score on what the user gets. Filters with stock behind them, not macro F1.
- Give every attribute its own threshold. They are different problems and one dial cannot serve all of them.
- Some attributes are a process problem. Style needs a form field, not a network.
The brief was nine thousand products and broken filters. Five of the six attributes come back from seventeen words of supplier copy, four of them well enough to ship untouched. The sixth was never in the text and the useful output there is not a model at all, it is one more required field on the supplier form.
See how the encoder groups things it was never told about
Embedding Similarity Explorer
The room attribute is recovered from listings that never mention a room, because a bath mat sits near other bathroom things in embedding space. This shows that similarity directly, and where it stops working.
Free, no signup. Runs in the browser.
The models behind the encoder
Deep Learning Cheatsheet
MiniLM is a distilled transformer used here without any fine tuning. This is every architecture and training concept on one page, including why freezing it was the right call on four word product titles.
Free, one page, LAD branded. No signup.
Companion projects. Support Ticket Triage is the case where fine tuning did beat frozen embeddings, and explains the difference. Review Themes at Scale is the other project where the answer turned out to be labelling rather than modelling.
[…] Product Catalogue Auto-Tagging […]