Hide code cell source

import ast
import re
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

from tqdm import tqdm
from collections import Counter
from IPython.display import display, HTML

Content#

  1. Read Data

  2. Data Normalization & Preprocessing

  3. Exploratory Data Analysis (EDA)

    3.1. Product Distribution by Category

    3.2. Product Distribution by Country

    3.3. Product Distribution by Country and Category

    3.4. Product Distribution by Brand and Category

    3.5. Rating Range by Category

    3.6. Price Ranges by Category

    3.7. Price–Rating Correlation

    3.8. Price–Rating Correlation by Category

    3.9. Price–Rating Correlation (Excluding Pet Products)

  4. Book Category Analysis

  5. AI Summary Tags Analysis

  6. Voice of Customer (VoC) & Root Cause Analysis

    6.1. Text Cleaning and Preprocessing

    6.2. Tag-to-Review Similarity Matching

    6.3. Draft Test Set Construction

    6.4. Issue Tag Classification (RCA Taxonomy)

    6.4.1 Unsupervised Methods

    6.4.2 Semi-Supervised Methods

    6.5. Results and Model Comparison

    6.6. Full Inference Pipeline

  7. Business Impact Scoring

7.1. Weighted Business Impact Score

Read Data#

df_products = pd.read_csv('data/products.csv')
df_products.head()
id name category rating price_kzt author url detail_info ai_positive_tags ai_negative_tags ai_summary_text country brand
0 2657408 ПНК «Красная нить» Нить особо прочная, цвет кр... home 4.9 502.0 NaN https://www.flip.kz/catalog?prod=2657408 {'Бренд': 'ПНК «Красная нить»', 'Страна-произв... ['прочность', 'тонкая нить', 'подходит для кожи'] NaN Мнение клиентов: Нить отмечают за высокую проч... Россия ПНК «Красная нить»
1 2567952 Унисон Комплект полотенец вафельных «Let it Snow» home 4.8 3115.0 NaN https://www.flip.kz/catalog?prod=2567952 {'Торговая марка': 'Унисон', 'Документ о соотв... ['красивый принт', 'праздничный дизайн', 'боль... NaN Мнение клиентов: Покупатели отмечают привлекат... Россия NaN
2 2657424 ПНК «Красная нить» Нить особо прочная, цвет ко... home 4.9 502.0 NaN https://www.flip.kz/catalog?prod=2657424 {'Бренд': 'ПНК «Красная нить»', 'Страна-произв... ['прочность', 'тонкая нить', 'подходит для кожи'] NaN Мнение клиентов: Нить отмечают за высокую проч... Россия ПНК «Красная нить»
3 1160459 Pasabahce Кувшин с крышкой home 4.8 1879.0 NaN https://www.flip.kz/catalog?prod=1160459 {'Производитель': 'Pasabahce', 'Серия': 'Kosem... NaN NaN NaN Турция NaN
4 593708 Мультидом Трейдинг Терка для корейской моркови home 4.7 1455.0 NaN https://www.flip.kz/catalog?prod=593708 {'Производитель': 'Мультидом Трейдинг', 'Докум... ['острые лезвия', 'тонкая соломка', 'удобная',... NaN Клиенты считают, что терка обладает очень остр... NaN NaN
df_products.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 28297 entries, 0 to 28296
Data columns (total 13 columns):
 #   Column            Non-Null Count  Dtype  
---  ------            --------------  -----  
 0   id                28297 non-null  int64  
 1   name              28297 non-null  object 
 2   category          28297 non-null  object 
 3   rating            27995 non-null  float64
 4   price_kzt         28288 non-null  float64
 5   author            4791 non-null   object 
 6   url               28297 non-null  object 
 7   detail_info       28297 non-null  object 
 8   ai_positive_tags  22156 non-null  object 
 9   ai_negative_tags  2504 non-null   object 
 10  ai_summary_text   22247 non-null  object 
 11  country           21083 non-null  object 
 12  brand             8747 non-null   object 
dtypes: float64(2), int64(1), object(10)
memory usage: 2.8+ MB
None
df_products.describe()
id rating price_kzt
count 2.829700e+04 27995.000000 28288.00000
mean 2.343398e+06 4.803847 2935.64773
std 1.204522e+06 0.339558 4526.42119
min 1.207000e+03 1.000000 65.00000
25% 1.465939e+06 4.700000 899.75000
50% 2.252064e+06 4.900000 1694.50000
75% 2.913040e+06 5.000000 3262.25000
max 6.012465e+06 5.000000 117459.00000

Data Normalization & Preprocessing#

def normalize_details(x):
    if not x:
        return None, None
    
    try:
        data = ast.literal_eval(x)
    except (ValueError, SyntaxError):
        return None, None
    
    return data.get('Страна-производитель'), data.get('Бренд')
df_products[['country', 'brand']] = (
    df_products['detail_info']
    .apply(lambda x: pd.Series(normalize_details(x)))
)
df_products['rating'] = df_products['rating'].fillna(0).astype(float)
df_products['price_kzt'] = df_products['price_kzt'].fillna(0).astype(float)
df_products['ai_negative_tags'] = df_products['ai_negative_tags'].apply(
    lambda x: ast.literal_eval(x) if pd.notnull(x) else None
)

df_products['ai_positive_tags'] = df_products['ai_positive_tags'].apply(
    lambda x: ast.literal_eval(x) if pd.notnull(x) else None
)
df_products.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 28297 entries, 0 to 28296
Data columns (total 13 columns):
 #   Column            Non-Null Count  Dtype  
---  ------            --------------  -----  
 0   id                28297 non-null  int64  
 1   name              28297 non-null  object 
 2   category          28297 non-null  object 
 3   rating            28297 non-null  float64
 4   price_kzt         28297 non-null  float64
 5   author            4791 non-null   object 
 6   url               28297 non-null  object 
 7   detail_info       28297 non-null  object 
 8   ai_positive_tags  22156 non-null  object 
 9   ai_negative_tags  2504 non-null   object 
 10  ai_summary_text   22247 non-null  object 
 11  country           21083 non-null  object 
 12  brand             8747 non-null   object 
dtypes: float64(2), int64(1), object(10)
memory usage: 2.8+ MB

Exploratory Data Analysis (EDA)#

Product Distribution by Category

df_products['category'].value_counts().plot(kind='bar')
plt.show()
_images/0376da42fea36d597ff648e597525df4c11233814e5c450416f362235e7c7dcb.png

Product Distribution by Country

Hide code cell source

counts = df_products['country'].value_counts()
top_counts = counts.head(10)
other_counts = counts.iloc[10:].sum()

plot_country = top_counts.copy()
plot_country['Other'] = other_counts

plot_country.plot(
    kind='pie',
    autopct='%1.1f%%',
    startangle=90,
    figsize=(6,6)
)

plt.ylabel('')
plt.title('Country Distribution (Top 10)')
plt.show()
_images/0124be2564e663ec9690438de9a12f104b1b15a53d1dce08e498f4ba070283ae.png

Product Distribution by Country and Category

Hide code cell source

categories = df_products['category'].unique().tolist()

fig, ax = plt.subplots(3, 2, figsize=(14, 14))
fig.suptitle('Top 6 countries suppliers by category')
ax = ax.flatten()

for i, category in enumerate(categories[:len(ax)]):
    counts = df_products[df_products['category'] == category]['country'].value_counts()
    
    top_counts = counts.head(6)
    other_counts = counts.iloc[6:].sum()

    plot_country = top_counts.copy()
    if other_counts > 0:
        plot_country['Other'] = other_counts

    plot_country.plot(
        kind='pie',
        autopct='%1.1f%%',
        startangle=90,
        ax=ax[i],
        rot=45
    )

    ax[i].set_title(f'Category: {category}')
    ax[i].set_ylabel('')
    for text in ax[i].texts:
        text.set_rotation(15)

for j in range(len(categories), len(ax)):
    ax[j].set_visible(False)

plt.tight_layout()
plt.show()
_images/636f16c021c42e8c98b526bfb80155c455489d1725b1ac806eb929b43cc63c0e.png

Product Distribution by Brand and Category

Hide code cell source

categories = df_products['category'].unique().tolist()
categories.remove('books')

fig, ax = plt.subplots(2, 2, figsize=(14, 10))
fig.suptitle('Top 15 brands by category')
ax = ax.flatten()

for i, category in enumerate(categories[:len(ax)]):
    counts = df_products[df_products['category'] == category]['brand'].value_counts()
    top_counts = counts.head(15)
    top_counts.plot(
        kind='bar',
        ax=ax[i]
    )

    ax[i].set_title(f'Category: {category}')
    ax[i].set_ylabel('')

for j in range(len(categories), len(ax)):
    ax[j].set_visible(False)

plt.tight_layout()
plt.show()
_images/afc844ec5ae3b850cb0fc50131458586b41af718b83a565eabcb07f505c73396.png

Rating Range by Category

sns.boxplot(df_products, x='rating', hue='category', orient='h', showfliers=False)
plt.title('Rating range by category')
plt.grid()
plt.show()
_images/0b179656da79067d66897c2c83140a7b6e747c176510b60ac5c8f74bb299e5c7.png

Price Ranges by Category

sns.boxplot(df_products, x='price_kzt', hue='category', orient='h', showfliers=False)
plt.title('Price ranges by category')
plt.grid()
plt.show()
_images/60a4c2149451779a4cabe187557970544770768f8a78e1cbc461f845120569d6.png

Price–Rating Correlation

sns.scatterplot(df_products, x='price_kzt', y='rating', hue='category')
plt.show()
_images/1f67f4936e342dc8be90285f2fcfaaf0c7d3e00830bdb5fa6b4796166f7159b2.png

Price–Rating Correlation by Category

Hide code cell source

fig, ax = plt.subplots(3, 2, figsize=(10, 10))
ax = ax.flatten() 
categories = df_products['category'].unique().tolist()

for i, cat in enumerate(categories):
    sns.scatterplot(data=df_products[df_products['category']==cat], x='price_kzt', y='rating', ax=ax[i])
    ax[i].set_title(f'Price VS Rating for: {cat}')

for j in range(len(categories), len(ax)):
    ax[j].set_visible(False) 

plt.tight_layout()
plt.title('Price VS Rating Correlation')
plt.show()
_images/ede6d727bd60e4afaa6b0f7e5d753217eccf86a4e98a3c605b2369264239ce47.png

Price–Rating Correlation (Excluding Pet Products)

sns.scatterplot(df_products[(df_products['category']!='pet')&(df_products['rating']>3)], x='price_kzt', y='rating', hue='category')
plt.show()
_images/d8762a1aef8e4233c8071424319fe9e2dcb604a602ccac5dc24968946ada5c44.png

Books#

Hide code cell source

def to_int(value):
    if value and str(value).isdigit():
        return int(value)
    else:
        return None

Hide code cell source

df_books = df_products[df_products['category']=='books'].copy()
df_books['detail_info'] = df_books['detail_info'].apply(lambda x: ast.literal_eval(x))
df_books[['Издательство', 'Бумага', 'Язык', 'Переплет', 'Количество страниц', 'Дата выхода']] = df_books['detail_info'].apply(
    lambda x: pd.Series([
        x.get('Издательство'),
        x.get('Бумага'),
        x.get('Язык'),
        x.get('Переплет'),
        to_int(x.get('Количество страниц')),
        to_int(x.get('Дата выхода'))
    ]) if isinstance(x, dict) else pd.Series([None] * 6)
)
df_books[['Количество страниц', 'Дата выхода']] = df_books[
    ['Количество страниц', 'Дата выхода']
].astype('Int64')

Distribution of books by publication

df_books['Издательство'].value_counts().head(25).plot(kind='bar', figsize=(15, 4))
plt.title('Distribution of books by publication')
plt.grid()
plt.show()
_images/bc177cb596aacd491acbe0ff82c1dc0446355846e1f5bc14706a6e9822bcb11a.png

Distribution of books by page size

plt.figure(figsize=(10, 4))
sns.histplot(data=df_books, x='Количество страниц', kde=True)
plt.title('Distribution of books by page size')
plt.grid()
plt.show()
_images/8c5dcc6601e56370a170136f376e5cb85f624213a9a7c822eed7ebcae3b6c38e.png
plt.figure(figsize=(10, 4))
sns.histplot(data=df_books[df_books['Количество страниц']<=1000], x='Количество страниц', kde=True, stat='count')
plt.title('Distribution of books by page size in range (0; 1000)')
plt.grid()
plt.show()
_images/924471546a25ce2f3ce20d7fb129bac7382bd3825cae47ea3f1cddd2ed9a22c6.png

Distribution of the books based on the language

df_books['Язык'].dropna().str.split(',').explode().str.strip().value_counts()
Язык
русский        5264
казахский       153
английский       96
немецкий          5
корейский         3
турецкий          2
японский          2
итальянский       1
испанский         1
грузинский        1
белорусский       1
Name: count, dtype: int64

Distribution of the books based on paper type

df_books['Бумага'].value_counts()
Бумага
офсетная                   1933
газетная                    816
типографская                271
офсетная, пухлая            187
мелованная                  154
пухлая, типографская        126
картон                       34
газетная, пухлая             33
пухлая                       26
ламинированные               14
кремовый офсет               13
тонированная                  7
глянцевая                     5
офсетная, мелованная          4
creamy пухлая                 3
матовая, мелованная           3
классик                       2
ПВХ                           2
белая                         2
creamy пухлая, офсетная       1
дизайнерская бумага           1
мелованная, офсетная          1
мелованная, матовая           1
кремовый офсет, пухлая        1
Name: count, dtype: int64

Pairwise Correlation of Numerical Variables

sns.pairplot(df_books.drop(columns=['id']))
plt.show()
_images/3be77528fc236c8c3703a0cdb788326a5dd28f07cbe9bc168e6be12b118715d7.png

Distribution of books by publication year

df_books['Дата выхода'].value_counts().sort_index().plot(kind='bar', figsize=(10, 5))
plt.title('Distribution of books by publication year')
plt.grid()
plt.show()
_images/43431c5b55f85988ed017fa1581bbc8eea43982f7e7ec82a80e7a264f07667d7.png

AI-Summary Tags Analysis#

from wordcloud import WordCloud
positive_tags = df_products['ai_positive_tags'].explode().dropna().astype(str).tolist()
negative_tags = df_products['ai_negative_tags'].explode().dropna().astype(str).tolist()

Positive Tag Distribution (Top 20)

df_products['ai_positive_tags'].explode().value_counts().head(20).plot(kind='barh')
plt.show()
_images/64f508211466a5e1a20854d320216327536c01c92fbf39d7c883b7e4780cf9b2.png

Negative Tag Distribution (Top 20)

df_products['ai_negative_tags'].explode().value_counts().head(20).plot(kind='barh')
plt.show()
_images/7404c5482f3873d68edba130f15fa7e5a6552fa8ba512cacbff7acac93763fba.png

Positive Tags Word Cloud Analysis

Hide code cell source

plt.figure(figsize=(10, 6))
wc_positive = WordCloud(
    width=1000,
    height=600,
    background_color='white',
    colormap='Blues'
).generate(' '.join(positive_tags))

plt.imshow(wc_positive, interpolation='bilinear')
plt.axis('off')
plt.title('Positive Tags Word Cloud')
plt.show()
_images/1c3c919a2fa7070d4bb414c7c473e90f39f8a5f355ed7d6e39d20192db81becc.png

Negative Tags Word Cloud Analysis

Hide code cell source

plt.figure(figsize=(10, 6))
wc_negative = WordCloud(
    width=1000,
    height=600,
    background_color='white',
    colormap='Reds'
).generate(' '.join(negative_tags))

plt.imshow(wc_negative, interpolation='bilinear')
plt.axis('off')
plt.title('Negative Tags Word Cloud')
plt.show()
_images/27432eba53cae0774a8c63409c692eeff06984ad01f0171e85fab7c904f12fa2.png

Hide code cell source

pos_counts = Counter(positive_tags)
neg_counts = Counter(negative_tags)

scaled_pos = {k: v/max(pos_counts.values()) for k, v in pos_counts.items()}
scaled_neg = {k: v/max(neg_counts.values()) for k, v in neg_counts.items()}

all_words = {**scaled_pos, **scaled_neg}

Hide code cell source

def coloring_tags(word, *args, **kwargs):
    if word in pos_counts:
        return 'blue'
    else:
        return 'red'

Positive vs Negative Tags Word Cloud

Hide code cell source

wc = WordCloud(
    width=1000,
    height=600,
    background_color='white'
).generate_from_frequencies(all_words)

wc.recolor(color_func=coloring_tags)
plt.figure(figsize=(10, 6))
plt.imshow(wc, interpolation='bilinear')
plt.axis('off')
plt.title('Positive vs Negative Tags Word Cloud')
plt.show()
_images/a007412e1e8f7c6c26f0f34f7da612c28d123ec7c71d9ee3432265d800e72b31.png

Positive Tag Frequency by Category

Hide code cell source

fig, ax = plt.subplots(3, 2, figsize=(10, 10))
ax = ax.flatten() 
categories = df_products['category'].unique().tolist()

for i, cat in enumerate(categories):
    (
        df_products[df_products['category']==cat]['ai_positive_tags']
        .explode().value_counts().head(20)
        .plot(kind='barh', ax=ax[i])
    )
    ax[i].set_title(f'Positive Tag Freq for: {cat}')
    ax[i].grid()

for j in range(len(categories), len(ax)):
    ax[j].set_visible(False) 

plt.tight_layout()
plt.title('Positive Tag Frequency by Category')
plt.show()
_images/70eb77a28b56a6edb3ba9abb47b329b95b5ee131ac6d2bdfee4c60c4df57b47b.png

Negative Tag Frequency by Category

Hide code cell source

fig, ax = plt.subplots(3, 2, figsize=(13, 10))
ax = ax.flatten() 
categories = df_products['category'].unique().tolist()

for i, cat in enumerate(categories):
    (
        df_products[df_products['category']==cat]['ai_negative_tags']
        .explode().value_counts().head(20)
        .plot(kind='barh', ax=ax[i], color='r')
    )
    ax[i].set_title(f'Negative Tag Freq for: {cat}')
    ax[i].grid()

for j in range(len(categories), len(ax)):
    ax[j].set_visible(False) 

plt.tight_layout()
plt.title('Negative Tag Frequency by Category')
plt.show()
_images/3c75e615fd6d28348486f8b267075b44149bf234a6fa71c823a03760539b4038.png

Voice of Customer (VoC) & Root Cause Analysis#

There used AI summaries and review tags to identify root causes of customer dissatisfaction, prioritized issues by business impact.

from pymorphy3 import MorphAnalyzer
from sentence_transformers import SentenceTransformer, util
df_reviews = pd.read_csv('data/product_reviews.csv', index_col=False)
df_reviews = df_reviews.copy().reset_index(names='id')
df_reviews.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 431043 entries, 0 to 431042
Data columns (total 8 columns):
 #   Column      Non-Null Count   Dtype  
---  ------      --------------   -----  
 0   id          431043 non-null  int64  
 1   product_id  431043 non-null  int64  
 2   date        431043 non-null  object 
 3   author      426859 non-null  object 
 4   rating      431043 non-null  float64
 5   pros        200352 non-null  object 
 6   cons        143392 non-null  object 
 7   text        398765 non-null  object 
dtypes: float64(1), int64(2), object(5)
memory usage: 26.3+ MB

We propose a practical approach to RCA and Voice of Customer analysis when raw review labeling is unavailable, but AI generated summaries and positive/negative tags are already available. This provides a strong foundation for identifying product issues, although access to richer data, such as sales, returns, or operational metrics would allow for a more advanced analysis.

Our dataset includes product information and customer reviews. For each product with negative tags, we estimate the share of each problem. First both review texts and negative tags are cleaned and lemmatized. Each review is then split into n-grams, where n equals the length of the tag being matched. Next we use the paraphrase-multilingual-MiniLM-L12-v2 (emb size = 384) embedding model to encode both tags and review chunks. Cosine similarity is calculated between each tag embedding and the embeddings of review chunks, producing a similarity matrix.

If the maximum similarity exceeds a predefined threshold the tag is considered detected in that review, otherwise it is treated as not matched. Using argmax we can also identify the review chunk that is most similar to the tag. Based on these matches we calculate the share of each negative tag within a product reviews. This metric helps rank issues in the RCA analysis and identify the most prominent customer pain points.

Finally, similar tags can be clustered into broader problem groups, which supports higher-level RCA categorization and allows issues to be ranked by severity, for example as low, high, or critical.

Similarity search pipeline

Negative Tag Percentage vs Rating

Hide code cell source

def count_neg_per(pos_tags, neg_tags):
    if neg_tags is None:
        return 0
    if pos_tags is None:
        return 1
    return len(neg_tags)/(len(neg_tags)+len(pos_tags))

df_products['neg_per'] = df_products.apply(lambda row: count_neg_per(row['ai_positive_tags'], row['ai_negative_tags']), axis=1)

sns.scatterplot(df_products[~df_products['ai_negative_tags'].isnull()], x='rating', y='neg_per')
plt.title('Negative Tag Percentage vs Rating')
plt.grid()
plt.show()
_images/42c274859f6925231803824322bee82ba83ccdd83af416a2f7163da20b751845.png

Text Cleaning and Preprocessing#

morph = MorphAnalyzer()
NEGATIONS = {'не', 'ни', 'нет'}


def clean_text(text: str) -> str:
    if pd.isna(text):
        return ''
    text = str(text).lower().strip()
    text = re.sub(r'[^а-яёa-z0-9\s]', ' ', text) #leave only text and numbers
    text = re.sub(r'\s+', ' ', text).strip() #removing extra spaces
    return text


#negations are an important part of text analysis. In our case we join negation 
# with the following word using an underscore to avoid splitting negated expressions when generating n-grams
def bind_negations(tokens: list[str]) -> list[str]:
    bound = []
    i = 0

    while i < len(tokens):
        if tokens[i] in NEGATIONS and i + 1 < len(tokens):
            bound.append(f'{tokens[i]}_{tokens[i + 1]}')
            i += 2
        else:
            bound.append(tokens[i])
            i += 1

    return bound


def lemmatize(text: str) -> list[str]:
    text = clean_text(text)
    if not text:
        return []

    words = text.split()
    lemmas = [morph.parse(w)[0].normal_form for w in words]
    lemmas = bind_negations(lemmas)

    return lemmas


def lemmatize_text(text: str) -> str:
    return ' '.join(lemmatize(text))
mask = df_products['ai_negative_tags'].notna() #products with neg tags
df_products.loc[mask, 'tag_lemma'] = df_products.loc[mask, 'ai_negative_tags'].apply(
    lambda tags: [lemmatize_text(tag) for tag in tags if tag]
)
def merge_text(*args):
    return ' '.join(
        str(arg) for arg in args
        if pd.notna(arg) and str(arg).strip()
    )

review_mask = df_reviews['product_id'].isin(df_products[mask]['id']) #reviews where products have neg tags
df_reviews.loc[review_mask, 'full_text'] = df_reviews[review_mask].apply(
    lambda row: merge_text(row['cons'], row['pros'], row['text']), axis=1
)

tqdm.pandas()
df_reviews.loc[review_mask, 'review_lemma_text'] = df_reviews.loc[review_mask, 'full_text'].progress_apply(
    lambda x: lemmatize_text(x)
)
merged = df_reviews.merge(
    df_products[~df_products['tag_lemma'].isna()][['id', 'tag_lemma']],
    left_on='product_id',
    right_on='id',
    how='right',
    suffixes=('', '_')
)
merged = merged.drop('id_', axis=1)

Tag-to-Review Similarity Matching#

minilm_model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

def n_grams(text: str, n: int) -> list[str]:
    words = str(text).split()
    if len(words) < n:
        return [str(text).strip()] if str(text).strip() else []

    windows = [
        ' '.join(words[i:i + n])
        for i in range(len(words) - n + 1)
    ]
    return list(dict.fromkeys(w.replace('_', ' ') for w in windows))

def match_tags_in_review(
    review_lemma_text: str,
    tag_lemmas: list[str],
    threshold: float = 0.85
) -> list[str]:
    if not review_lemma_text or not isinstance(tag_lemmas, list):
        return []

    review_lemma_text = str(review_lemma_text).strip()
    clean_tags = [tag.strip() for tag in tag_lemmas if isinstance(tag, str) and tag.strip()]

    if not review_lemma_text or not clean_tags:
        return []

    matched = []

    for tag in clean_tags:
        n = len(tag.split()) #window size
        grams = n_grams(review_lemma_text, n=n)

        if not grams:
            continue

        texts = [tag] + grams
        embs = minilm_model.encode(texts, normalize_embeddings=True)
        tag_emb = embs[0]
        gram_embs = embs[1:]

        sims = util.cos_sim(tag_emb, gram_embs)[0]
        best_score = float(sims.max())

        if best_score >= threshold:
            matched.append(tag)

    return list(dict.fromkeys(matched))
tqdm.pandas()
merged['matched_tags'] = merged.progress_apply(
    lambda row: match_tags_in_review(row['review_lemma_text'], row['tag_lemma']),
    axis=1
)

Callculate Share of Negative Tags

\[ \text{Share}_{p,t} = \frac{ \#\{r \in R_p : \max(\cos(e_t, e_c)) > 0.85\} }{ |R_p| } \]

\(p\) - product

\(t\) - tag

\(R_p\) - all reviews of product \(p\)

\(e_t\) - embedding of tag \(t\)

\(e_c\) - embedding of chunk \(c\)

review_count = (
    merged.groupby('product_id', as_index=False)
    .size()
    .rename(columns={'size': 'review_count'})
)

tag_mentions = merged[['product_id', 'id', 'matched_tags']].explode('matched_tags')
tag_mentions = tag_mentions[tag_mentions['matched_tags'].notna()].copy()
tag_mentions = tag_mentions[tag_mentions['matched_tags'].astype(str).str.strip() != '']

tag_frequency = (
    tag_mentions
    .drop_duplicates(['product_id', 'id', 'matched_tags'])
    .groupby(['product_id', 'matched_tags'], as_index=False)
    .size()
    .rename(columns={
        'matched_tags': 'tag_lemma',
        'size': 'frequency'
    })
)
tag_frequency = tag_frequency.merge(review_count, on='product_id', how='left')

tag_frequency['share_pct'] = (
    tag_frequency['frequency'] / tag_frequency['review_count'] * 100
).round(1)

tag_frequency = tag_frequency.sort_values(
    ['product_id', 'frequency'],
    ascending=[True, False]
)

tag_frequency = tag_frequency.merge(
    df_products[['id', 'name', 'category', 'rating', 'price_kzt']],
    left_on='product_id',
    right_on='id',
    how='left'
)[['product_id', 'name', 'category', 'rating', 'price_kzt', 'tag_lemma', 'frequency', 'review_count', 'share_pct', ]]
tag_frequency.to_csv('data/tag_frequency.csv', index=False)
tag_frequency.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 2136 entries, 0 to 2135
Data columns (total 9 columns):
 #   Column        Non-Null Count  Dtype  
---  ------        --------------  -----  
 0   product_id    2136 non-null   int64  
 1   name          2136 non-null   object 
 2   category      2136 non-null   object 
 3   rating        2136 non-null   float64
 4   price_kzt     2136 non-null   float64
 5   tag_lemma     2136 non-null   object 
 6   frequency     2136 non-null   int64  
 7   review_count  2136 non-null   int64  
 8   share_pct     2136 non-null   float64
dtypes: float64(3), int64(3), object(3)
memory usage: 150.3+ KB
tag_frequency.sort_values(['share_pct'], ascending=False).head(15)
product_id name category rating price_kzt tag_lemma frequency review_count share_pct
2098 5380803 Мнямс Корм для кошек со вкусом кролика с зелен... pet 3.3 694.0 отказаться есть 2 2 100.0
153 731714 Вертлюг разборный №3, большой pet 4.0 1864.0 зазор 2 2 100.0
592 1450125 Citoderm Мыло дерматологическое для собак и кошек pet 4.4 1146.0 запах дегтярный 2 2 100.0
2083 5296803 Caffè Borbone Кофе в капсулах со сливочным вку... food 4.6 2162.0 растворимый кофе 4 4 100.0
1974 4343595 Faenbei Лакомство-пюре для кошек со вкусом трески pet 3.0 990.0 отказ кот 2 2 100.0
2084 5296805 Caffè Borbone Кофе в капсулах со вкусом фундук... food 4.6 2162.0 растворимый кофе 4 4 100.0
2090 5342239 Щетка-перчатка от шерсти животных pet 3.4 1290.0 тряпка 2 2 100.0
2094 5378122 Мнямс Корм для кошек со вкусом говядины с клюк... pet 3.3 694.0 отказаться есть 2 2 100.0
1544 2760381 Верный друг Присыпка для уборки pet 4.5 1600.0 распыление 2 2 100.0
1794 3397466 Faenbei Набор пюре для кошек «Курица, треска, ... pet 4.0 990.0 кушать 2 2 100.0
2096 5378654 Мнямс Корм для кошек со вкусом индейки с тыкво... pet 3.3 694.0 отказаться есть 2 2 100.0
904 1837074 Yami-Yami Автокормушка-автопоилка pet 2.5 2912.0 вода выливаться 2 2 100.0
2100 5381294 Мнямс Корм для кошек со вкусом лосося с яблоко... pet 3.3 694.0 отказаться есть 2 2 100.0
2107 5390803 Мнямс Корм для кошек старше 7 лет «Кусочки в с... pet 4.5 470.0 не_нравиться 2 2 100.0
2106 5390700 Мнямс Корм для кошек «Кусочки в соусе с форелью» pet 4.5 470.0 не_нравиться 2 2 100.0
tag_frequency['tag_lemma'].value_counts().head(20).plot(kind='bar')
plt.title('Distribution of tags top 20')
plt.show()
_images/732b9b5fb543dcc0784231fb0f0e1bb5c783e3c0a1a2ece0cb45bd9c0041969b.png

Hide code cell source

fig, ax = plt.subplots(1, 3, figsize=(14, 6))
sns.boxplot(data=tag_frequency, x='category', y='share_pct', ax=ax[0])
sns.pointplot(
    data=tag_frequency,
    x='category',
    y='share_pct',
    estimator='mean',
    errorbar=None,
    color='red',
    ax=ax[0]
)
ax[0].set_title('Share % Range by Category with Mean')
ax[0].set_xlabel('Category')
ax[0].set_ylabel('share_pct')
ax[0].tick_params(axis='x', rotation=45)
ax[0].grid()

tag_frequency['category'].value_counts().sort_values().plot(
    kind='barh',
    ax=ax[1],
    color='steelblue'
)
ax[1].set_title('Number of Records by Category')
ax[1].set_xlabel('Count')
ax[1].set_ylabel('Category')
ax[1].grid()

tag_frequency.groupby('category')['tag_lemma'].nunique().sort_values().plot(
    kind='barh',
    ax=ax[2],
    color='steelblue'
)
ax[2].set_title('Number of Unique Tags by Category')
ax[2].set_xlabel('Unique tag count')
ax[2].set_ylabel('Category')
ax[2].grid()

plt.tight_layout()
plt.show()
_images/e2604670e1aa1ebd3509601d4f9b2444aa4ed4253efdb2f92f4dd257e4af015d.png

Issue Tag Classification (RCA Taxonomy)#

The next step is to cluster the main product issues. There are 733 unique keywords (neg tags), which is too many to classify manually.

tag_frequency = pd.read_csv('data/tag_frequency.csv', index_col=False)
tag_frequency.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 2136 entries, 0 to 2135
Data columns (total 10 columns):
 #   Column        Non-Null Count  Dtype  
---  ------        --------------  -----  
 0   product_id    2136 non-null   int64  
 1   name          2136 non-null   object 
 2   category      2136 non-null   object 
 3   rating        2136 non-null   float64
 4   price_kzt     2136 non-null   float64
 5   tag_lemma     2136 non-null   object 
 6   frequency     2136 non-null   int64  
 7   review_count  2136 non-null   int64  
 8   share_pct     2136 non-null   float64
 9   class         2136 non-null   object 
dtypes: float64(3), int64(3), object(4)
memory usage: 167.0+ KB
tag_frequency[['category', 'tag_lemma']].groupby('category')['tag_lemma'].unique()
category
beauty    [крошиться, хлипкий ручка, размазываться, заби...
books     [тонкий бумага, плохой перевод, не_цветной, чё...
food      [слишком сладкий, много примесь, неравномерный...
home      [тонкий, без инструкция, крупный отверстие, ма...
pet       [царапина, боль животный, выдирать шерсть, тон...
Name: tag_lemma, dtype: object

Draft Test Set Construction#

from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score, accuracy_score, classification_report

For evaluation we constructed a draft test set, where tags are manually sampled across classes.

draft_test_set = {
    'appearance_issue': [
        'белые след', 'белый налёт', 'бледный оттенок', 'жирный блеск', 'затемнять',
        'неверный оттенок', 'неверный цвет', 'неподходящий оттенок', 'краска пачкать', 'не цветной',
        'пятно', 'цвет обложка', 'чернильный пятно', 'чёрно белые картинка', 'серый цвет',
        'слабый цвет', 'тёмный', 'тёмный пятно', 'масляный текстура', 'жёлтый страница', 'старое'
    ],
    'durability_issue': [
        'быстро выветриваться', 'быстро подсыхать', 'быстро растекаться', 'крошиться', 'осыпаться',
        'отклеиваться', 'протекание', 'размокать', 'быстро ломаться', 'залом', 'засохнуть',
        'позолота стираться', 'порвать плёнка', 'потрепать', 'расклеиваться', 'ломаный лист', 'сломать',
        'засохнуть кусочек', 'вздутый банк', 'вздутый паучить', 'дата изготовление', 'просрочить', 'срок годность'
    ],
    'health_reaction_issue': [
        'аллергия', 'вызывать перхоть', 'вызывать раздражение', 'высыпание', 'жжение',
        'перхоть', 'покраснение', 'раздражение кожа', 'боль животный', 'вызывать рвота',
        'диарея', 'кашель', 'перхоть остаться', 'рвота', 'рвота у собака', 'вздутие'
    ],
    'ingredient_issue': [
        'примесь', 'гнилой зерно', 'много масло', 'много примесь', 'много соль',
        'много соус', 'много специя', 'наличие робуста', 'не из твёрдый мука', 'вкусовой добавка',
        'ингредиент', 'много кукуруза', 'много овощ', 'много пшеница', 'непонятный частица',
        'низкий мясо', 'плесень'
    ],
    'taste_issue': [
        'приторно', 'банановый вкус', 'без вкус', 'безвкусность', 'вкус недолговечный', 
        'вкус странный', 'вкус сыр', 'высокий солёность', 'не по вкус', 'аппетит', 'без аппетит',
        'отказ от корм', 'прогорклый вкус', 'прогорклость'
    ],
    'smell_issue': [
        'запах спирт', 'иной аромат', 'искусственный запах', 'неприятный запах', 'нестойкий аромат',
        'резкий запах', 'резкий отдушка', 'аромат', 'без запах', 'запах', 'запах застарелость', 
        'искусственный аромат', 'слабый аромат', 'слабый запах', 'запах протухший мясо',
        'прогорклый запах'
    ],
    'material_quality_issue': [
        'жёсткий щетина', 'липкий', 'липкость', 'мягкий щетина', 'тонкий ткань',
        'хлипкий корпус', 'хлипкий ручка', 'бумага', 'бумага газетный', 'газетный бумага',
        'грязный помятый', 'жёсткий окончание', 'мягкий обложка', 'мягкий переплёт', 'тонкий бумага',
        'грязный', 'жёсткость', 'наличие осадка', 'качество страница'
    ],
    'print_quality_issue': [
        'банальный сюжет', 'без иллюстрация', 'главный героиня', 'грамматический ошибка', 
        'иллюстрация в середина', 'качество печать', 'неинтересный сюжет', 'неинтересный', 
        'недосказанность', 'урезанный версия', 'мелкий схема', 'нечёткий шрифт', 'брак на корешок'
    ],
    'performance_issue': [
        'быстро смываться', 'впитывать средство', 'плохо смывать', 'средний перекрытие', 'тяжело смываться',
        'комковаться', 'ложиться неравномерно', 'плохо перемалывать', 'плохо подниматься', 
        'плохо растворяться', 'замутнять вода', 'мутить вода', 'не впитывать', 'не комковаться'
    ],
    'size_issue': [
        'крупный блёстка', 'маленький объём', 'маленький размер', 'малый объём', 'маленький формат',
        'мало детектив', 'мало место', 'мало слово', 'крупный измельчение', 'крупный помол', 
        'маленький порция', 'маленький упаковка'
    ],
    'usability_issue': [
        'без дозатор', 'не для жирный кожа', 'неудобный дозатор', 'неудобный крышка', 'неудобный тара',
        'трудно наноситься', 'трудно открываться', 'широкий горлышко', 'без плёнка', 'не для каждый',
        'неплотный крышка', 'слабый крышка', 'сложно открываться', 'без батарейка', 'без инструкция',
        'неудобный горлышко', 'бортик отсутствовать'
    ]
}

draft_test = pd.DataFrame(
    [(cls, tag) for cls, tags in draft_test_set.items() for tag in tags],
    columns=['class', 'tag_lemma']
)
draft_test['class_num'] = draft_test['class'].astype('category').cat.codes
draft_test.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 182 entries, 0 to 181
Data columns (total 3 columns):
 #   Column     Non-Null Count  Dtype 
---  ------     --------------  ----- 
 0   class      182 non-null    object
 1   tag_lemma  182 non-null    object
 2   class_num  182 non-null    int8  
dtypes: int8(1), object(2)
memory usage: 3.1+ KB

Hide code cell source

draft_test.groupby('class')['tag_lemma'].nunique().sort_values().plot(
    kind='barh',
    color='steelblue'
)
plt.title('Draft Test Classes Distribution')
plt.xlabel('Unique tag count')
plt.ylabel('Category')
plt.grid()
plt.show()
_images/3a02c008cdbe90b6d5271bdcd043e1ffdc6200314d44c5cfbc9893f8a01fb459.png
def evaluate_method(
    y_true: list, 
    y_pred: list, 
    method: str='Culstering with Embeddings',
    emb_model: str='paraphrase-multilingual-MiniLM-L12-v2',
    acc: bool=False
) -> dict:
    ari = adjusted_rand_score(y_true, y_pred)
    nmi = normalized_mutual_info_score(y_true, y_pred)

    print(f'Method: {method} | EmbModel: {emb_model}')
    print(f'ARI: {ari:.4f}')
    print(f'NMI: {nmi:.4f} \n')

    result = {
        'method': method,
        'emb_model': emb_model,
        'ARI': ari,
        'NMI': nmi
    }
    if acc:
        result['accuracy_score'] =  accuracy_score(y_true, y_pred)

    return result

Unsupervised Methods#

To construct the root cause taxonomy without labeled data, we first explored a set of unsupervised clustering methods. As embedding models, we tested paraphrase-multilingual-MiniLM-L12-v2 and BAAI/bge-m3.

Tag Embedding Clustering#

from sklearn.manifold import TSNE
from sklearn.cluster import AgglomerativeClustering

minilm_model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
bge_model = SentenceTransformer('BAAI/bge-m3')

This simple method provides embeddings for these tags and then applies a clustering method to identify problem classes.

unique_tags = tag_frequency.drop_duplicates('tag_lemma').copy()
tags = unique_tags['tag_lemma'].str.replace('_', ' ', regex=False).to_list()
tag_embeddings = minilm_model.encode(
    tags,
    normalize_embeddings=True,
    show_progress_bar=True,
)
tag_embeddings_bge = bge_model.encode(
    tags,
    normalize_embeddings=True,
    show_progress_bar=True,
)
c12_model = AgglomerativeClustering(
    n_clusters=12,
    linkage='ward'
)
classes = c12_model.fit_predict(tag_embeddings)
classes_bge = c12_model.fit_predict(tag_embeddings_bge)

Hide code cell source

tsne = TSNE(n_components=3, perplexity=25, max_iter=1000, random_state=42)
x_tsne = tsne.fit_transform(tag_embeddings)

fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(111, projection="3d")
scatter = ax.scatter(x_tsne[:, 0], x_tsne[:, 1], x_tsne[:, 2], c=classes, cmap="tab20", s=30)
ax.set_title("3D t-SNE of Tag Classes")
ax.set_xlabel("t-SNE 1")
ax.set_ylabel("t-SNE 2")
ax.set_zlabel("t-SNE 3")
plt.colorbar(scatter, label="Cluster")
plt.show()
huggingface/tokenizers: The current process just got forked, after parallelism has already been used. Disabling parallelism to avoid deadlocks...
To disable this warning, you can either:
	- Avoid using `tokenizers` before the fork if possible
	- Explicitly set the environment variable TOKENIZERS_PARALLELISM=(true | false)
_images/b15b9c6f564e0465ecfcea80561dcee59de02ddbd42dbb6dd6450bf18def36ad.png
results = []

y_pred = draft_test[['tag_lemma']].merge(
    plot_df[['tag', 'class']],
    left_on='tag_lemma',
    right_on='tag',
    how='left'
)['class']
y_pred_bge = draft_test[['tag_lemma']].merge(
    pd.DataFrame({'tag':tags, 'class': classes_bge}),
    left_on='tag_lemma',
    right_on='tag',
    how='left'
)['class']

results.append(evaluate_method(
    draft_test['class_num'], 
    y_pred, 
    'AgglomerativeClustering with Tag Embeddings'
))
results.append(evaluate_method(
    draft_test['class_num'], 
    y_pred_bge, 
    'AgglomerativeClustering with Tag Embeddings',
    'BAAI/bge-m3'
))
Method: AgglomerativeClustering with Tag Embeddings | EmbModel: paraphrase-multilingual-MiniLM-L12-v2
ARI: 0.2446
NMI: 0.4571 

Method: AgglomerativeClustering with Tag Embeddings | EmbModel: BAAI/bge-m3
ARI: 0.3115
NMI: 0.5252 

We see that clustering only using embedding is not enough accurate. Because clusters mainly capture semantic similarity, but do not form pure business categories of problems:

for class_id in sorted(plot_df['class'].unique()):
    class_tags = plot_df.loc[plot_df['class'] == class_id, 'tag'].tolist()
    print(f'\nClass {class_id} | size={len(class_tags)}')
    print(class_tags[:30])
Class 0 | size=109
['плохой перевод', 'сложно читаться', 'отсутствие аудио', 'недосказанность', 'сложный задание', 'неудачный перевод', 'некачественный переплёт', 'отсутствие диск', 'некачественный оформление', 'сложно читать', 'тяжёлый язык', 'запачканность', 'затянутость', 'боль животный', 'плохо сматываться', 'бортик плохо фиксироваться', 'много кровь', 'старорусский слово', 'тяжело читаться', 'искажённый имя', 'непонятный задание', 'неправильный буква', 'неинтересный кошка', 'подделка', 'повреждение', 'плохо приниматься', 'трудный чтение', 'раздражение кожа', 'сложно смываться', 'нестойкий']

Class 1 | size=81
['тонкий бумага', 'не цветной', 'чёрно белые страница', 'сложный текст', 'маленький формат', 'мелкий шрифт', 'тонкий карта', 'газетный бумага', 'обложка', 'мало казахстан', 'тонкий страница', 'без иллюстрация', 'черно белый печать', 'тонкий лист', 'бумага газетный', 'мало страница', 'чёрно белые картинка', 'бумага', 'карабин', 'хлипкий ручка', 'несоответствие цвет', 'иллюстрация в середина', 'качество страница', 'тёмный бумага', 'серый страница', 'маркировка', 'бумага серый', 'библейский отсылка', 'цвет отличаться', 'неровный страница']

Class 10 | size=37
['тяжёлый содержание', 'много повтор', 'крупный отверстие', 'цена', 'крупный палочка', 'много примесь', 'размер', 'больший', 'слишком больший', 'тяжёлый слог', 'высокий цена', 'крупный измельчение', 'объём', 'крупный', 'много гранула', 'крупный гранула', 'много углевод', 'дорого', 'трата деньга', 'большой объём', 'вздутый банк', 'состояние', 'крупный блёстка', 'много цифра', 'докупать', 'измельчение', 'помол крупноватый', 'большой размер', 'крупный кусочек', 'крупный кусок']

Class 11 | size=19
['много вода', 'плавать корм', 'жидкость', 'не для вода', 'не впитывать', 'не подходить для аквариум с сом', 'не пить вода', 'замутнять вода', 'мутить вода', 'жидкий консистенция', 'без батарейка', 'жидкий', 'вода выливаться', 'много жидкость', 'водянистый', 'впитывать средство', 'мутный вода', 'жидковато', 'питься тяжело']

Class 2 | size=85
['тонкий обложка', 'поверхностный информация', 'мягкий обложка', 'тонкий картон', 'хлипкий', 'некачественный пластик', 'материал', 'неоднородный стекло', 'мягкий резина', 'мусорить', 'тонкий металл', 'ржаветь', 'пылить', 'качество мех', 'жёсткий материал', 'липкий', 'стягивать кожа', 'слабый увлажнение', 'излишек продукт', 'сушить кожа', 'шерсть жёсткий', 'неравномерный обжарка', 'мягкий щетина', 'не для густой шерсть', 'много продукт', 'пересолить', 'горелый', 'твёрдый шелуха', 'солома', 'тонкий ткань']

Class 3 | size=49
['нет пример', 'не для каждый', 'не для короткошёрстный', 'не держаться', 'слишком простой', 'не восторг', 'не есть', 'не комковаться', 'не катиться', 'слишком сладкий', 'нет оттенок', 'бортик отсутствовать', 'нет новизна', 'неинтересный', 'не прочный', 'слишком твёрдый', 'без плёнка', 'не нравиться', 'чересчур сладкий', 'малосладкий', 'не окрашивать', 'не подходить', 'не есть крыса', 'слишком жидкий', 'без дозатор', 'слишком сладкое', 'не прикоснуться', 'нет зип замок', 'безвкусность', 'невкусный']

Class 4 | size=103
['качество бумага', 'типографский брак', 'скучный сюжет', 'опечатка', 'урезанный версия', 'неуказанный возраст', 'возраст завысить', 'жёсткий окончание', 'на любитель', 'научный язык', 'брак', 'сбивчивый повествование', 'чрезмерный жестокость', 'шаблонность', 'сложный восприятие', 'банальный сюжет', 'неинтересный сюжет', 'качество печать', 'незавершённый концовка', 'без инструкция', 'качество', 'быстро крошиться', 'разгрызание', 'картинка запаздывать', 'быстро рваться', 'качество обработка', 'двусторонний', 'оформление', 'скучный', 'легко гнуться']

Class 5 | size=120
['мягкий переплёт', 'сократить текст', 'пачкаться срез', 'перевод', 'порода', 'тонкий', 'царапина', 'выдирать шерсть', 'тонкий стелька', 'тонковатый', 'разваливаться', 'рваться', 'крошиться', 'тонкий крепление', 'помятый край', 'перо выпадать', 'нитка распускаться', 'зазор', 'толстой', 'размазываться', 'склеивать ресница', 'индейка', 'вздутие', 'подчёркивать морщина', 'расклеиваться', 'много болтовня', 'осыпаться', 'кривая наклейка', 'примесь', 'липнуть']

Class 6 | size=53
['позолота стираться', 'отсутствие мясо', 'подача корм', 'низкий мясо', 'банановый вкус', 'пшеничный мука', 'пальмовый масло', 'ежедневный питание', 'много кукуруза', 'много пшеница', 'плохо есть', 'похожий на кетчуп', 'кокосовый привкус', 'без аппетит', 'сушить губа', 'аппетит', 'масляный текстура', 'мало специя', 'мясной кусочек', 'постоянный кормление', 'много специя', 'с рис', 'печь губа', 'сырой', 'специя специфический', 'много соус', 'много масло', 'преобладание яблоко', 'отказ от корм', 'поедаемость']

Class 7 | size=44
['мало задание', 'мало новый', 'мало текст', 'маленький размер', 'малый количество', 'малый размер', 'малый диаметр', 'мало место', 'маленький', 'недолговечный', 'мало резьба', 'жирность', 'краткость изложение', 'маленький объём', 'малый объём', 'маленький упаковка', 'короткий', 'малый вес', 'маленький фасовка', 'мелкий шарик', 'мелкий помол', 'малый порция', 'мелкий крупа', 'мало порция', 'мало', 'мелкий хлебец', 'маленький порция', 'несоответствие размер', 'редкий кусочек', 'короткий длина']

Class 8 | size=33
['химический запах', 'запах', 'горелый запах', 'неприятный запах', 'резкий запах', 'нестойкий запах', 'запах резина', 'сильный запах', 'слабый запах', 'слабый аромат', 'специфический запах', 'искусственный запах', 'запах застарелость', 'запах дегтярный', 'плохой запах', 'резковатый запах', 'не впитывать запах', 'мятный запах', 'искусственный аромат', 'сильный ароматизатор', 'запах спирт', 'неудержание запах', 'короткий срок запах', 'тяжёлый запах', 'запах книга', 'неприятный аромат', 'без запах', 'запах протухший мясо', 'аромат', 'нестойкий аромат']

Class 9 | size=40
['вкус', 'не по вкус', 'менять вкус', 'слабый вкус', 'горький привкус', 'химозный вкус', 'искусственный вкус', 'горький вкус', 'непривычный привкус', 'вкус странный', 'горьковатый вкус', 'посторонний привкус', 'неприятный вкус', 'кислый вкус', 'не вкусный', 'ржаный привкус', 'вкус не для весь', 'усилитель вкус', 'без вкус', 'вкус недолговечный', 'неравномерный вкус', 'специфический вкус', 'несоответствие вкус', 'изменчивый вкус', 'странный вкус', 'неприятный привкус', 'некофейный вкус', 'прогорклый вкус', 'нет вкус', 'непонятный вкус']

Tag + Category Embedding Clustering#

tag_cat_pairs = (
    unique_tags['category'] + ': ' +
    unique_tags['tag_lemma'].str.replace('_', ' ', regex=False)
).tolist()

tag_cat_embeddings = minilm_model.encode(
    tag_cat_pairs,
    normalize_embeddings=True,
    show_progress_bar=True,
)

tag_cat_embeddings_bge = bge_model.encode(
    tag_cat_pairs,
    normalize_embeddings=True,
    show_progress_bar=True,
)

Hide code cell source

c12_model = AgglomerativeClustering(
    n_clusters=12,
    linkage='ward'
)
classes = c12_model.fit_predict(tag_cat_embeddings)
classes_bge = c12_model.fit_predict(tag_cat_embeddings)

tsne = TSNE(n_components=3, perplexity=25, max_iter=1000, random_state=42)
x_tsne = tsne.fit_transform(tag_cat_embeddings)

fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(111, projection="3d")
scatter = ax.scatter(x_tsne[:, 0], x_tsne[:, 1], x_tsne[:, 2], c=classes, cmap="tab20", s=30)
ax.set_title("3D t-SNE of Tag Classes")
ax.set_xlabel("t-SNE 1")
ax.set_ylabel("t-SNE 2")
ax.set_zlabel("t-SNE 3")
plt.colorbar(scatter, label="Cluster")
plt.show()
_images/3bf83a70abb31664e5050ffa5d6d6bcc32085777c10d60cffbb347be23e26fb6.png
y_pred = draft_test[['tag_lemma']].merge(
    plot_df[['tag', 'class']],
    left_on='tag_lemma',
    right_on='tag',
    how='left'
)['class']
y_pred_bge = draft_test[['tag_lemma']].merge(
    pd.DataFrame({'tag':tags, 'class': classes_bge}),
    left_on='tag_lemma',
    right_on='tag',
    how='left'
)['class']

results.append(evaluate_method(
    draft_test['class_num'], 
    y_pred, 
    'AgglomerativeClustering with Tag + Category Embedding'
))
results.append(evaluate_method(
    draft_test['class_num'], 
    y_pred_bge, 
    'AgglomerativeClustering with Tag + Category Embedding',
    'BAAI/bge-m3'
))
Method: AgglomerativeClustering with Tag + Category Embedding | EmbModel: paraphrase-multilingual-MiniLM-L12-v2
ARI: 0.0943
NMI: 0.3491 

Method: AgglomerativeClustering with Tag + Category Embedding | EmbModel: BAAI/bge-m3
ARI: 0.0943
NMI: 0.3491 

When category information was directly added to tag text embeddings, clustering quality increased in terms of category separation, but the resulting clusters became dominated by product category rather than problem type. This suggests that category aware embeddings are useful for within category analysis, but less suitable for building a global RCA taxonomy across categories.

Tag Embedding + One-Hot Category Features#

cat_one_hot = pd.get_dummies(unique_tags['category']).astype(float)
print('For MiniLM-L12-v2 Embeddings:')
print(tag_embeddings.shape,'+', cat_one_hot.shape)
merged_embeddings = np.concatenate([tag_embeddings, cat_one_hot], axis=1)
print('=', merged_embeddings.shape, '\n')

print('For bge-m3 Embeddings:')
print(tag_embeddings_bge.shape,'+', cat_one_hot.shape)
merged_embeddings_bge = np.concatenate([tag_embeddings_bge, cat_one_hot], axis=1)
print('=', merged_embeddings_bge.shape)
For MiniLM-L12-v2 Embeddings:
(773, 384) + (773, 5)
= (773, 389) 

For bge-m3 Embeddings:
(773, 1024) + (773, 5)
= (773, 1029)

Hide code cell source

c12_model = AgglomerativeClustering(
    n_clusters=12,
    linkage='ward'
)
classes = c12_model.fit_predict(merged_embeddings)
classes_bge = c12_model.fit_predict(merged_embeddings_bge)

tsne = TSNE(n_components=3, perplexity=25, max_iter=1000, random_state=42)
x_tsne = tsne.fit_transform(merged_embeddings)

fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(111, projection="3d")
scatter = ax.scatter(x_tsne[:, 0], x_tsne[:, 1], x_tsne[:, 2], c=classes, cmap="tab20", s=30)
ax.set_title("3D t-SNE of Tag Classes")
ax.set_xlabel("t-SNE 1")
ax.set_ylabel("t-SNE 2")
ax.set_zlabel("t-SNE 3")
plt.colorbar(scatter, label="Cluster")
plt.show()
_images/13cf10fc56aa60fa3619719190eacb90484dd4159ba6b6dadf51c6f08a795a4f.png
y_pred = draft_test[['tag_lemma']].merge(
    plot_df[['tag', 'class']],
    left_on='tag_lemma',
    right_on='tag',
    how='left'
)['class']
y_pred_bge = draft_test[['tag_lemma']].merge(
    pd.DataFrame({'tag':tags, 'class': classes_bge}),
    left_on='tag_lemma',
    right_on='tag',
    how='left'
)['class']

results.append(evaluate_method(
    draft_test['class_num'], 
    y_pred, 
    'AgglomerativeClustering with Tag + One Hot Cat Embedding'
))
results.append(evaluate_method(
    draft_test['class_num'], 
    y_pred_bge, 
    'AgglomerativeClustering with Tag + One Hot Cat Embedding',
    'BAAI/bge-m3'
))
Method: AgglomerativeClustering with Tag + One Hot Cat Embedding | EmbModel: paraphrase-multilingual-MiniLM-L12-v2
ARI: 0.1094
NMI: 0.3706 

Method: AgglomerativeClustering with Tag + One Hot Cat Embedding | EmbModel: BAAI/bge-m3
ARI: 0.1043
NMI: 0.3570 

We still observe the same issue as in previous method, but with slightly better metrics, as category embeddings do not have a significant impact compared to the prev method.

Semi-Supervised Methods#

Nearest-Anchor Similarity (assign class = max similarity anchor)#

The classification method works as follows:
For each input tag, we compute its embedding and measure similarity against all anchor embeddings.
The tag is then assigned to the class of the most similar anchor.

The anchor set consists of prelabled tags grouped into known classes. For each class we also build a draft anchor set, where we randomly sample a small number of representative tags (6 per class). These anchors are intended to capture the core semantics of each class without introducing too much repetition.

anchor_tags = draft_test.groupby('class')[['class', 'class_num', 'tag_lemma']].apply(
    lambda x: x.sample(6, random_state=41),
).reset_index(drop=True)
anchor_tags = anchor_tags.sample(frac=1, random_state=41).reset_index(drop=True)

draft_test_updated = draft_test[~draft_test['tag_lemma'].isin(anchor_tags['tag_lemma'])]
# for a fair test we remove tags that already exist in the anchor set
anchor_tags.head(10)
class class_num tag_lemma
0 size_issue 7 крупный помол
1 print_quality_issue 6 иллюстрация в середина
2 appearance_issue 0 цвет обложка
3 print_quality_issue 6 урезанный версия
4 taste_issue 9 без аппетит
5 durability_issue 1 залом
6 taste_issue 9 вкус сыр
7 usability_issue 10 неплотный крышка
8 size_issue 7 маленький формат
9 taste_issue 9 вкус недолговечный
draft_test_updated.groupby('class')['tag_lemma'].nunique().sort_values().plot(
    kind='barh',
    color='steelblue'
)
plt.title('Draft Test Updated Classes Distribution')
plt.xlabel('Unique tag count')
plt.ylabel('Category')
plt.grid()
plt.show()
_images/b73c4048d23cc47acb0a3a2c3f4ebd10af965bb061b5db01378f35790ed9e24e.png
def predict_class_by_anchor(
    tags: list[str],
    anchor_tags: pd.DataFrame,
    emb_model: SentenceTransformer,
) -> pd.DataFrame:
    tag_embeddings = emb_model.encode(
        tags, 
        normalize_embeddings=True,
        show_progress_bar=True
    )
    anchor_tag_embeddings = emb_model.encode(
        anchor_tags['tag_lemma'].to_list(),
        normalize_embeddings=True,
        show_progress_bar=True
    )

    sims = util.cos_sim(tag_embeddings, anchor_tag_embeddings)
    best_ids = np.argmax(sims, axis=1)

    return pd.DataFrame({
        'class': anchor_tags.iloc[best_ids]['class'].values,
        'class_num': anchor_tags.iloc[best_ids]['class_num'].values,
        'tag_lemma': tags,
        'matched_anchor': anchor_tags.iloc[best_ids]['tag_lemma'].values,
        'similarity': sims[np.arange(sims.shape[0]), best_ids]
    })
pred_df = predict_class_by_anchor(draft_test_updated['tag_lemma'].to_list(), anchor_tags, minilm_model)
pred_bge_df = predict_class_by_anchor(draft_test_updated['tag_lemma'].to_list(), anchor_tags, bge_model)

results.append(
    evaluate_method(
        draft_test_updated['class_num'].to_list(), pred_df['class_num'].to_list(), 
        'Nearest-Anchor by Embedding', acc=True
    )
)
print(classification_report(draft_test_updated['class_num'].to_list(), pred_df['class_num'].to_list()))

results.append(
    evaluate_method(
        draft_test_updated['class_num'].to_list(), pred_bge_df['class_num'].to_list(), 
        'Nearest-Anchor by Embedding',
        'BAAI/bge-m3', acc=True
    )
)
print(classification_report(draft_test_updated['class_num'].to_list(), pred_bge_df['class_num'].to_list()))
Method: Nearest-Anchor by Embedding | EmbModel: paraphrase-multilingual-MiniLM-L12-v2
ARI: 0.2418
NMI: 0.5011 

              precision    recall  f1-score   support

           0       0.53      0.60      0.56        15
           1       0.32      0.53      0.40        17
           2       0.36      0.40      0.38        10
           3       0.50      0.27      0.35        11
           4       1.00      0.69      0.82        13
           5       0.36      0.50      0.42         8
           6       0.00      0.00      0.00         7
           7       0.67      1.00      0.80         6
           8       0.82      0.90      0.86        10
           9       0.80      0.50      0.62         8
          10       0.83      0.45      0.59        11

    accuracy                           0.53       116
   macro avg       0.56      0.53      0.53       116
weighted avg       0.57      0.53      0.53       116

Method: Nearest-Anchor by Embedding | EmbModel: BAAI/bge-m3
ARI: 0.4013
NMI: 0.5883 

              precision    recall  f1-score   support

           0       0.62      0.67      0.65        15
           1       0.78      0.82      0.80        17
           2       0.88      0.70      0.78        10
           3       0.57      0.36      0.44        11
           4       0.73      0.62      0.67        13
           5       0.50      0.62      0.56         8
           6       0.10      0.14      0.12         7
           7       0.86      1.00      0.92         6
           8       1.00      0.70      0.82        10
           9       0.50      0.75      0.60         8
          10       0.50      0.45      0.48        11

    accuracy                           0.63       116
   macro avg       0.64      0.62      0.62       116
weighted avg       0.66      0.63      0.64       116
pred_df.head(10)
class class_num tag_lemma matched_anchor similarity
0 durability_issue 1 белые след осыпаться 0.637784
1 durability_issue 1 белый налёт осыпаться 0.637983
2 appearance_issue 0 бледный оттенок слабый цвет 0.739381
3 size_issue 7 жирный блеск крупный помол 0.680680
4 appearance_issue 0 неверный цвет слабый цвет 0.843784
5 appearance_issue 0 неподходящий оттенок неверный оттенок 0.920777
6 appearance_issue 0 краска пачкать чернильный пятно 0.765497
7 appearance_issue 0 не цветной слабый цвет 0.839165
8 appearance_issue 0 пятно чернильный пятно 0.840146
9 appearance_issue 0 цвет обложка слабый цвет 0.750441
pred_bge_df.head(10)
class class_num tag_lemma matched_anchor similarity
0 appearance_issue 0 белые след чернильный пятно 0.533115
1 material_quality_issue 4 белый налёт мягкий переплёт 0.587633
2 appearance_issue 0 бледный оттенок неверный оттенок 0.722985
3 ingredient_issue 3 жирный блеск много масло 0.646701
4 appearance_issue 0 неверный цвет неверный оттенок 0.878375
5 appearance_issue 0 неподходящий оттенок неверный оттенок 0.895321
6 performance_issue 5 краска пачкать впитывать средство 0.590542
7 appearance_issue 0 не цветной неверный оттенок 0.724858
8 appearance_issue 0 пятно чернильный пятно 0.803314
9 appearance_issue 0 цвет обложка слабый цвет 0.605489

Problems with this method (assign class = max similarity anchor) is if one anchor is slightly closer it dominates and nearest anchor changes class easily.

Top-k Voting Anchors#

Instead of using best anchor only, this method provides voting 3-5 top anhcors.

from collections import defaultdict

def predict_class_by_topk_weighted(
    tags: list[str],
    anchor_tags: pd.DataFrame,
    emb_model: SentenceTransformer,
    top_k: int = 5,
) -> pd.DataFrame:
    if len(anchor_tags) < top_k:
        raise TypeError('Top k should be less than count of anchor tags!')

    tag_embeddings = emb_model.encode(
        tags, 
        normalize_embeddings=True,
        show_progress_bar=True
    )
    anchor_tag_embeddings = emb_model.encode(
        anchor_tags['tag_lemma'].to_list(),
        normalize_embeddings=True,
        show_progress_bar=True
    )

    sims = util.cos_sim(tag_embeddings, anchor_tag_embeddings).cpu().numpy()
    top_ids = np.argsort(sims, axis=1)[:, ::-1][:, :top_k]
    top_sims = np.take_along_axis(sims, top_ids, axis=1)
    class_scores = []
    class_evidence = []

    for ids, scores in zip(top_ids, top_sims):
        classes = anchor_tags['class_num'].values[ids]
        anchors = anchor_tags['tag_lemma'].values[ids]

        score_dict = defaultdict(float)
        evid_dict = defaultdict(list)

        for cls, sim, anchor in zip(classes, scores, anchors):
            score_dict[cls] += sim
            evid_dict[cls].append((anchor, round(float(sim), 2)))

        class_scores.append(score_dict)
        class_evidence.append(evid_dict)

    results = []

    for score_dict, evid_dict in zip(class_scores, class_evidence):
        best_class_num = max(score_dict, key=score_dict.get)
        best_class = anchor_tags[anchor_tags['class_num'] == best_class_num]['class'].iloc[0]
        best_score = score_dict[best_class_num]

        top_anchors = sorted(
            evid_dict[best_class_num],
            key=lambda x: x[1],
            reverse=True
        )

        total = sum(score_dict.values())
        confidence = score_dict[best_class_num] / total

        results.append({
            'class': best_class,
            'class_num': best_class_num,
            'score': best_score,
            'confidence': confidence,
            'top_anchors': top_anchors[:top_k]
        })

    df_results = pd.DataFrame(results)
    df_results['tag'] = tags
    return df_results
pred_df = predict_class_by_topk_weighted(draft_test_updated['tag_lemma'].to_list(), anchor_tags, minilm_model, 3)
pred_bge_df = predict_class_by_topk_weighted(draft_test_updated['tag_lemma'].to_list(), anchor_tags, bge_model, 3)

results.append(
    evaluate_method(
        draft_test_updated['class_num'].to_list(), pred_df['class_num'].to_list(), 
        'Top-k voting Anchors', acc=True
    )
)
print(classification_report(draft_test_updated['class_num'].to_list(), pred_df['class_num'].to_list()))

results.append(
    evaluate_method(
        draft_test_updated['class_num'].to_list(), pred_bge_df['class_num'].to_list(), 
        'Top-k voting Anchors',
        'BAAI/bge-m3', acc=True
    )
)
print(classification_report(draft_test_updated['class_num'].to_list(), pred_bge_df['class_num'].to_list()))
Method: Top-k voting Anchors | EmbModel: paraphrase-multilingual-MiniLM-L12-v2
ARI: 0.2775
NMI: 0.5221 

              precision    recall  f1-score   support

           0       0.56      0.60      0.58        15
           1       0.39      0.71      0.50        17
           2       0.50      0.50      0.50        10
           3       0.50      0.27      0.35        11
           4       0.91      0.77      0.83        13
           5       0.33      0.38      0.35         8
           6       0.00      0.00      0.00         7
           7       0.67      1.00      0.80         6
           8       0.82      0.90      0.86        10
           9       0.80      0.50      0.62         8
          10       1.00      0.45      0.62        11

    accuracy                           0.57       116
   macro avg       0.59      0.55      0.55       116
weighted avg       0.60      0.57      0.56       116

Method: Top-k voting Anchors | EmbModel: BAAI/bge-m3
ARI: 0.4388
NMI: 0.6191 

              precision    recall  f1-score   support

           0       0.69      0.73      0.71        15
           1       0.82      0.82      0.82        17
           2       0.80      0.80      0.80        10
           3       0.57      0.36      0.44        11
           4       0.88      0.54      0.67        13
           5       0.50      0.62      0.56         8
           6       0.09      0.14      0.11         7
           7       0.86      1.00      0.92         6
           8       1.00      0.80      0.89        10
           9       0.55      0.75      0.63         8
          10       0.45      0.45      0.45        11

    accuracy                           0.65       116
   macro avg       0.66      0.64      0.64       116
weighted avg       0.68      0.65      0.65       116
pred_df.head(10)
class class_num score confidence top_anchors tag
0 durability_issue 1 0.637784 0.345273 [(осыпаться, 0.64)] белые след
1 durability_issue 1 0.637983 0.341188 [(осыпаться, 0.64)] белый налёт
2 appearance_issue 0 0.739381 0.346011 [(слабый цвет, 0.74)] бледный оттенок
3 size_issue 7 0.680680 0.345971 [(крупный помол, 0.68)] жирный блеск
4 appearance_issue 0 1.442583 0.734974 [(слабый цвет, 0.84), (чернильный пятно, 0.6)] неверный цвет
5 appearance_issue 0 0.920777 0.371489 [(неверный оттенок, 0.92)] неподходящий оттенок
6 appearance_issue 0 1.520413 0.710853 [(чернильный пятно, 0.77), (слабый цвет, 0.75)] краска пачкать
7 appearance_issue 0 0.839165 0.412334 [(слабый цвет, 0.84)] не цветной
8 appearance_issue 0 0.840146 0.341304 [(чернильный пятно, 0.84)] пятно
9 appearance_issue 0 1.469452 0.714820 [(слабый цвет, 0.75), (чернильный пятно, 0.72)] цвет обложка
pred_bge_df.head(10)
class class_num score confidence top_anchors tag
0 appearance_issue 0 1.539604 1.000000 [(чернильный пятно, 0.53), (неверный оттенок, ... белые след
1 appearance_issue 0 1.133981 0.658673 [(тёмный, 0.57), (жёлтый страница, 0.56)] белый налёт
2 appearance_issue 0 2.061123 1.000000 [(неверный оттенок, 0.72), (слабый цвет, 0.7),... бледный оттенок
3 ingredient_issue 3 0.646701 0.357700 [(много масло, 0.65)] жирный блеск
4 appearance_issue 0 1.598488 0.715987 [(неверный оттенок, 0.88), (слабый цвет, 0.72)] неверный цвет
5 appearance_issue 0 1.622366 0.691236 [(неверный оттенок, 0.9), (слабый цвет, 0.73)] неподходящий оттенок
6 performance_issue 5 0.590542 0.346217 [(впитывать средство, 0.59)] краска пачкать
7 appearance_issue 0 1.426581 0.678151 [(неверный оттенок, 0.72), (слабый цвет, 0.7)] не цветной
8 appearance_issue 0 1.428046 0.702793 [(чернильный пятно, 0.8), (затемнять, 0.62)] пятно
9 appearance_issue 0 1.184852 0.664475 [(слабый цвет, 0.61), (неверный оттенок, 0.58)] цвет обложка

We see that this method performs better, because of top k voting. While previous method one anchor might be randomly be more similar than others and class might be misslead. BAAI/bge-m3 model actually works better, better semantic smoothing, better aligns on low resource classes and better generalize noisy tags.

Linear Support Vector Classifier (LinearSVC)#

import joblib
from sklearn.svm import LinearSVC
X_train = minilm_model.encode(
    anchor_tags['tag_lemma'].to_list(),
    normalize_embeddings=True,
    show_progress_bar=True,
)
X_test = minilm_model.encode(
    draft_test_updated['tag_lemma'].to_list(),
    normalize_embeddings=True,
    show_progress_bar=True,
)
X_train_bge = bge_model.encode(
    anchor_tags['tag_lemma'].to_list(),
    normalize_embeddings=True,
    show_progress_bar=True,
)
X_test_bge = bge_model.encode(
    draft_test_updated['tag_lemma'].to_list(),
    normalize_embeddings=True,
    show_progress_bar=True,
)
y_train = anchor_tags['class_num'].to_numpy()
y_test = draft_test_updated['class_num'].to_numpy()

print(X_train.shape, y_train.shape, X_test.shape, y_test.shape)
print(X_train_bge.shape, y_train.shape, X_test_bge.shape, y_test.shape)
(66, 384) (66,) (116, 384) (116,)
(66, 1024) (66,) (116, 1024) (116,)
clf = LinearSVC(
    class_weight='balanced',
    max_iter=20000,
    C=5
)
clf.fit(X_train, y_train)
joblib.dump(clf, 'LinearSVC.pkl') 
y_pred = clf.predict(X_test)
results.append(evaluate_method(y_test, y_pred, 'LinearSVC', acc=True))
print(classification_report(y_test, y_pred))

clf = LinearSVC(
    class_weight='balanced',
    max_iter=20000,
    C=5
)
clf.fit(X_train_bge, y_train)
joblib.dump(clf, 'LinearSVC_bge_m3.pkl') 
y_pred = clf.predict(X_test_bge)
results.append(evaluate_method(y_test, y_pred, 'LinearSVC', 'BAAI/bge-m3', acc=True))
print(classification_report(y_test, y_pred))
Method: LinearSVC | EmbModel: paraphrase-multilingual-MiniLM-L12-v2
ARI: 0.3874
NMI: 0.5794 

              precision    recall  f1-score   support

           0       0.68      0.87      0.76        15
           1       0.53      0.59      0.56        17
           2       0.50      0.60      0.55        10
           3       0.57      0.36      0.44        11
           4       0.71      0.77      0.74        13
           5       0.75      0.38      0.50         8
           6       0.33      0.29      0.31         7
           7       0.83      0.83      0.83         6
           8       1.00      0.90      0.95        10
           9       0.80      0.50      0.62         8
          10       0.60      0.82      0.69        11

    accuracy                           0.65       116
   macro avg       0.66      0.63      0.63       116
weighted avg       0.66      0.65      0.64       116

Method: LinearSVC | EmbModel: BAAI/bge-m3
ARI: 0.5149
NMI: 0.6742 

              precision    recall  f1-score   support

           0       0.81      0.87      0.84        15
           1       0.81      0.76      0.79        17
           2       0.88      0.70      0.78        10
           3       0.83      0.45      0.59        11
           4       0.69      0.85      0.76        13
           5       0.83      0.62      0.71         8
           6       0.27      0.43      0.33         7
           7       1.00      1.00      1.00         6
           8       1.00      0.90      0.95        10
           9       0.50      0.62      0.56         8
          10       0.58      0.64      0.61        11

    accuracy                           0.72       116
   macro avg       0.75      0.71      0.72       116
weighted avg       0.76      0.72      0.73       116

LinearSVC with BAAI/bge-m3 embeddings achieved best performance, significantly outperforming MiniLM across all metrics, indicating that embedding quality is the main factor for classification performance in this task.

Few-Shot Classification using LLMs#

import dspy
from dspy.teleprompt import BootstrapFewShot
lm = dspy.LM(
    'ollama_chat/llama3.1:8b',
    api_base='http://localhost:11434',
    temperature=0.0
)
dspy.configure(lm=lm)
class Classify(dspy.Signature):
    tag = dspy.InputField()
    label = dspy.OutputField(
        desc = """
        Return exactly one label from the allowed set.

        Allowed labels:
        - appearance_issue
        - durability_issue
        - health_reaction_issue
        - ingredient_issue
        - material_quality_issue
        - performance_issue
        - print_quality_issue
        - size_issue
        - smell_issue
        - taste_issue
        - usability_issue
        """
    )

class TextClassifier(dspy.Module):
    def __init__(self):
        super().__init__()
        self.predict = dspy.Predict(Classify)

    def forward(self, tag):
        return self.predict(tag=tag)

def metric(example, pred, trace=None):
    return example.labels == pred.labels
# few-shot dataset
trainset = [
    dspy.Example(
        tag=row['tag_lemma'],
        label=row['class']
    ).with_inputs('tag')
    for _, row in anchor_tags.iterrows()
]
# optimizer = BootstrapFewShot(metric=metric)

# model = optimizer.compile(
#     TextClassifier(),
#     trainset=trainset
# )
# model.save('few_shot_clf.json')
model = TextClassifier()
model.load('few_shot_clf.json')
tqdm.pandas()
draft_test_updated.loc[:, 'y_pred'] = draft_test_updated['tag_lemma'].progress_apply(
    lambda x: model(tag=x).label
)
results.append(
    evaluate_method(
        draft_test_updated['class'], 
        draft_test_updated['y_pred'], 
        'Few-shot classification', 
        'llama3.1:8b',
        acc=True
    )
)
print(classification_report(draft_test_updated['class'], draft_test_updated['y_pred']))
Method: Few-shot classification | EmbModel: llama3.1:8b
ARI: 0.4343
NMI: 0.6263 

                        precision    recall  f1-score   support

      appearance_issue       0.57      0.80      0.67        15
      durability_issue       0.69      0.65      0.67        17
 health_reaction_issue       0.83      1.00      0.91        10
      ingredient_issue       0.79      1.00      0.88        11
material_quality_issue       0.78      0.54      0.64        13
     performance_issue       0.67      0.50      0.57         8
   print_quality_issue       0.00      0.00      0.00         7
            size_issue       0.60      0.50      0.55         6
           smell_issue       1.00      1.00      1.00        10
           taste_issue       1.00      0.38      0.55         8
       usability_issue       0.37      0.64      0.47        11

              accuracy                           0.67       116
             macro avg       0.66      0.64      0.63       116
          weighted avg       0.68      0.67      0.65       116

The LLM shows that short text classification is not well suited for models such as llama3.1:8b. It achieved average performance and outperformed all methods except LinearSVC.

Results and Model Comparison#

results_df = pd.DataFrame(results)
results_df
method emb_model ARI NMI accuracy_score
0 AgglomerativeClustering with Tag Embeddings paraphrase-multilingual-MiniLM-L12-v2 0.244605 0.457113 NaN
1 AgglomerativeClustering with Tag Embeddings BAAI/bge-m3 0.311521 0.525249 NaN
2 AgglomerativeClustering with Tag + Category Em... paraphrase-multilingual-MiniLM-L12-v2 0.094290 0.349141 NaN
3 AgglomerativeClustering with Tag + Category Em... BAAI/bge-m3 0.094290 0.349141 NaN
4 AgglomerativeClustering with Tag + One Hot Cat... paraphrase-multilingual-MiniLM-L12-v2 0.109406 0.370567 NaN
5 AgglomerativeClustering with Tag + One Hot Cat... BAAI/bge-m3 0.104338 0.356963 NaN
6 Nearest-Anchor by Embedding paraphrase-multilingual-MiniLM-L12-v2 0.241782 0.501106 0.534483
7 Nearest-Anchor by Embedding BAAI/bge-m3 0.401294 0.588265 0.629310
8 Top-k voting Anchors paraphrase-multilingual-MiniLM-L12-v2 0.277546 0.522067 0.568966
9 Top-k voting Anchors BAAI/bge-m3 0.438844 0.619084 0.646552
10 LinearSVC paraphrase-multilingual-MiniLM-L12-v2 0.387449 0.579354 0.646552
11 LinearSVC BAAI/bge-m3 0.514899 0.674198 0.724138
12 Few-shot classification llama3.1:8b 0.434334 0.626342 0.672414

Hide code cell source

def add_values(ax):
    for container in ax.containers:
        ax.bar_label(container, fmt='%.2f', fontsize=9)

fig, axes = plt.subplots(3, 1, figsize=(16, 12), sharex=True)

sns.barplot(data=results_df, x='method', y='ARI', hue='emb_model',ax=axes[0])
axes[0].set_title('ARI')
axes[0].set_xlabel('')
add_values(axes[0])
axes[0].grid()

sns.barplot(data=results_df, x='method', y='NMI', hue='emb_model', ax=axes[1])
axes[1].set_title('NMI')
axes[1].set_xlabel('')
add_values(axes[1])
axes[1].grid()

sns.barplot(data=results_df, x='method', y='accuracy_score', hue='emb_model',ax=axes[2])
axes[2].set_title('Accuracy')
axes[2].set_xlabel('')
axes[2].legend(loc='upper left')
add_values(axes[2])
axes[2].tick_params(axis='x', rotation=30)

axes[0].legend_.remove()
axes[1].legend_.remove()
plt.tight_layout()
plt.grid()
plt.show()
_images/6694aa431a822c1cd9506a8c1c7455287f412b12769fbe1dc59003aea0f64e62.png

For RCA taxonomy construction, we used two approaches unsupervised and semi-supervised learning to label product issue tags. As expected, semi-supervised methods outperformed classical clustering approaches. Among semi-supervised methods, embedding based approaches consistently outperformed LinearSVC in clustering related metrics, while few-shot classification with an LLM ranked second overall. However, it outperformed LinearSVC with MiniLM (paraphrase-multilingual-MiniLM-L12-v2) (emb size 384). Overall, BGE (BAAI/bge-m3) embeddings consistently performed better than MiniLM across all methods, likely due to their higher dimensionality (emb size 1024) and stronger semantic representation capacity.

results_df.to_csv('data/results_metrics.csv')

Full Inference Pipleine#

import joblib
from pymorphy3 import MorphAnalyzer
from sentence_transformers import SentenceTransformer

emb_model = SentenceTransformer('BAAI/bge-m3')
model = joblib.load('LinearSVC_bge_m3_final.pkl')
morph = MorphAnalyzer()
rca_classes = {
    0: 'appearance_issue',
    1: 'durability_issue',
    2: 'health_reaction_issue',
    3: 'ingredient_issue',
    4: 'material_quality_issue',
    5: 'performance_issue',
    6: 'print_quality_issue',
    7: 'size_issue',
    8: 'smell_issue',
    9: 'taste_issue',
    10: 'usability_issue'
}


def clean_text(text: str) -> str:
    if pd.isna(text):
        return ''
    text = str(text).lower().strip()
    text = re.sub(r'[^а-яёa-z0-9\s]', ' ', text)
    text = re.sub(r'\s+', ' ', text).strip()
    return text

def lemmatize(text: str) -> list[str]:
    text = clean_text(text)
    if not text:
        return []

    words = text.split()
    lemmas = [morph.parse(w)[0].normal_form for w in words]

    return lemmas

def lemmatize_text(text: str) -> str:
    return ' '.join(lemmatize(text))

def predict(tags: list[str]) -> list[str]:
    tags_lemma = [lemmatize_text(tag) for tag in tags]
    tag_embeddings = emb_model.encode(
        tags,
        normalize_embeddings=True,
        show_progress_bar=False
    )
    pred_ids = model.predict(tag_embeddings)
    pred_labels = [rca_classes[i] for i in pred_ids]

    return pred_labels
predict(['плохой вкус', 'плохая упаковка'])
['taste_issue', 'usability_issue']

Business Impact Scoring#

tag_frequency = pd.read_csv('data/tag_frequency.csv', index_col=False)
tag_frequency['class'] = predict(tag_frequency['tag_lemma'].to_list())
tag_frequency.head(10)
product_id name category rating price_kzt tag_lemma frequency review_count share_pct class
0 9359 Уголовный кодекс Республики Казахстан books 5.0 3758.0 тонкий бумага 2 11 18.2 material_quality_issue
1 17963 Хроники Амбера: Десять романов о Янтарном коро... books 4.5 11141.0 плохой перевод 2 17 11.8 print_quality_issue
2 18490 Ладушки. Пальчиковые игры для малышей. Развити... books 4.8 2029.0 не_цветной 4 15 26.7 appearance_issue
3 52547 Прописи. Пособие для детей 6-7 лет books 4.6 1411.0 чёрно белые страница 1 13 7.7 appearance_issue
4 55374 Игры, в которые играют люди: Психология челове... books 4.5 3041.0 сложно читаться 3 18 16.7 print_quality_issue
5 73876 Сборник задач по алгебре: учебное пособие для ... books 4.7 5852.0 тонкий бумага 2 9 22.2 material_quality_issue
6 75621 Математика. Часть 1. Для детей 5-6 лет books 5.0 414.0 нет_пример 1 5 20.0 print_quality_issue
7 85032 Думай и Богатей! books 4.7 3082.0 качество бумага 1 28 3.6 material_quality_issue
8 86160 Бухгалтерский учет в организациях (учебное пос... books 4.8 6256.0 типографский брак 1 5 20.0 print_quality_issue
9 89644 Совершенный код. Мастер-класс books 4.9 13875.0 тонкий бумага 1 5 20.0 material_quality_issue
tag_frequency.to_csv('data/tag_frequency.csv', index=False)
tag_frequency.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 2136 entries, 0 to 2135
Data columns (total 10 columns):
 #   Column        Non-Null Count  Dtype  
---  ------        --------------  -----  
 0   product_id    2136 non-null   int64  
 1   name          2136 non-null   object 
 2   category      2136 non-null   object 
 3   rating        2136 non-null   float64
 4   price_kzt     2136 non-null   float64
 5   tag_lemma     2136 non-null   object 
 6   frequency     2136 non-null   int64  
 7   review_count  2136 non-null   int64  
 8   share_pct     2136 non-null   float64
 9   class         2136 non-null   object 
dtypes: float64(3), int64(3), object(4)
memory usage: 167.0+ KB
tag_frequency['class'].value_counts().head(20).plot(kind='bar')
plt.title('Final RCA Taxonomy Distribution')
plt.show()
_images/d0132cc74e08f7988f2dcd646c2b644fb3403a564c06f0f2a0b4688b9f8384e2.png

Hide code cell source

fig, ax = plt.subplots(3, 2, figsize=(10, 10))
ax = ax.flatten() 
categories = tag_frequency['category'].unique().tolist()

for i, cat in enumerate(categories):
    (
        tag_frequency[tag_frequency['category']==cat]['class']
        .value_counts().head(20)
        .plot(kind='barh', ax=ax[i])
    )
    ax[i].set_title(f'RCA Taxonomy Distribution for: {cat}')
    ax[i].grid()

for j in range(len(categories), len(ax)):
    ax[j].set_visible(False) 

plt.tight_layout()
plt.show()
_images/5a0336f17eb2b42da51482c5f776849aee2b49335db673853efb2c70f89c91bb.png

Hide code cell source

order = (
    tag_frequency
    .groupby('class')['share_pct'].mean()
    .sort_values(ascending=False).index
)
plt.figure(figsize=(14, 6))
ax = sns.boxplot(data=tag_frequency, x='class', y='share_pct', order=order)
sns.pointplot(data=tag_frequency, x='class', y='share_pct', estimator='mean', color='red', order=order, ax=ax)
plt.title('Share % Range by RCA Class')
plt.xlabel('Category')
plt.ylabel('share_pct')
plt.xticks(rotation=45)
plt.grid(True)
plt.show()
_images/3f387f113d3dcc52822fcd4eef82f9e04bcab6840495124b1fe2b61c703e1381.png

Hide code cell source

pareto_df = (
    tag_frequency
    .groupby('class', as_index=False)
    .agg(
        count=('product_id', 'count'),
        share_pct=('share_pct', 'mean'),
        review_count=('review_count', 'mean'),
        rating=('rating', 'mean')
    )
)

#freq 
pareto_df = pareto_df.sort_values('count', ascending=False)
pareto_df['cum_share_freq'] = pareto_df['count'].cumsum() / pareto_df['count'].sum()

#impact
pareto_df['confidence'] = np.log1p(pareto_df['review_count'])
pareto_df['impact'] = pareto_df['share_pct'] * pareto_df['confidence']
pareto_df = pareto_df.sort_values('impact', ascending=False)
pareto_df['cum_share_imp'] = pareto_df['impact'].cumsum() / pareto_df['impact'].sum()

#rating
pareto_df['rating_impact'] = (5 - pareto_df['rating']) * pareto_df['confidence']
pareto_df = pareto_df.sort_values('rating_impact', ascending=False)
pareto_df['cum_share_rating'] = pareto_df['rating_impact'].cumsum() / pareto_df['rating_impact'].sum()


pareto_df = pareto_df.sort_values('count', ascending=False)
fig, axes = plt.subplots(3, 1, figsize=(14, 18))
axes[0].bar(pareto_df['class'], pareto_df['count'])
axes[0].set_title('Pareto 1: Frequency (Raw Counts)')
axes[0].tick_params(axis='x', rotation=45)
ax0b = axes[0].twinx()
ax0b.plot(pareto_df['class'], pareto_df['cum_share_freq'], color='red', marker='o')
ax0b.axhline(0.8, color='green', linestyle='--', alpha=0.7)

pareto_df = pareto_df.sort_values('impact', ascending=False)
axes[1].bar(pareto_df['class'], pareto_df['impact'])
axes[1].set_title('Pareto 2: Impact (Share × log(Reviews))')
axes[1].tick_params(axis='x', rotation=45)
ax1b = axes[1].twinx()
ax1b.plot(pareto_df['class'], pareto_df['cum_share_imp'], color='red', marker='o')
ax1b.axhline(0.8, color='green', linestyle='--', alpha=0.7)

pareto_df = pareto_df.sort_values('rating_impact', ascending=False)
axes[2].bar(pareto_df['class'], pareto_df['rating_impact'])
axes[2].set_title('Pareto 3: Rating Impact (Severity × Confidence)')
axes[2].set_ylabel('Rating Impact')
axes[2].tick_params(axis='x', rotation=45)
ax2b = axes[2].twinx()
ax2b.plot(pareto_df['class'], pareto_df['cum_share_rating'], color='red', marker='o')
ax2b.axhline(0.8, color='green', linestyle='--', alpha=0.7)

plt.tight_layout()
plt.show()
_images/318f98ab7d16a6c03c28fb98e86b07dfd6ac41f8cce50233687b81935ceb9725.png
pareto_df
class count share_pct review_count rating cum_share_freq confidence impact cum_share_imp rating_impact cum_share_rating
5 performance_issue 113 15.398230 18.920354 4.384071 0.944288 2.991742 46.067532 1.000000 1.842701 0.110548
10 usability_issue 72 20.452778 17.152778 4.377778 0.977996 2.898824 59.288995 0.883839 1.803712 0.218758
2 health_reaction_issue 47 33.753191 13.723404 4.351064 1.000000 2.689438 90.777128 0.409953 1.745274 0.323461
8 smell_issue 170 32.791765 13.517647 4.377647 0.750000 2.675365 87.729938 0.509067 1.665021 0.423350
1 durability_issue 304 46.348355 22.233553 4.491447 0.304307 3.145597 145.793269 0.164711 1.599702 0.519321
9 taste_issue 285 26.456140 11.726316 4.390526 0.577715 2.543672 67.295742 0.749579 1.550301 0.612327
0 appearance_issue 346 40.557803 21.511561 4.547399 0.161985 3.114029 126.298176 0.307397 1.409413 0.696882
7 size_issue 198 19.982828 16.116162 4.530303 0.670412 2.840023 56.751695 0.947955 1.333950 0.776909
4 material_quality_issue 299 24.851839 15.732441 4.535117 0.444288 2.817349 70.016316 0.673552 1.309738 0.855484
3 ingredient_issue 135 31.724444 9.829630 4.475556 0.891386 2.382286 75.576695 0.594450 1.249377 0.930437
6 print_quality_issue 167 22.357485 13.347305 4.564671 0.828184 2.663562 59.550551 0.816857 1.159527 1.000000

Pareto plots shows different perspectives on issue prioritization depending on the metric used. Based on frequency (Pareto 1) the most common issue classes are appearance_issue, durability_issue, material_quality_issue and taste_issue, which together account for a large proportion of all detected problems.

However when incorporating review volume through the impact score (Pareto 2)(share percentage weighted by review count confidence), durability_issue becomes the most important class, followed by appearance_issue, indicating that these issues are both widespread, due to stronger statistical support from review counts.

\[ \text{Impact}_{p,t} = \text{Share}_{p,t} \times \log(1 + \#\text{Reviews}) \]

The rating impact (Pareto 3) analysis provides a different view by highlighting issues associated with lower customer satisfaction. Here performance_issue, usability_issue, and health_reaction_issue rank highest, suggesting that although these issues may not be the most frequent, they have a stronger negative effect on product ratings. In contrast, highly frequent issues such as appearance_issue and durability_issue show a lower rating impact, indicating that customers may tolerate them more than issues directly affecting product experience.

\[ \text{Rating Impact}_{p,t} = (5 - \text{Rating}_{p,t}) \times \log(1 + \#\text{Reviews}) \]

From a business perspective, frequency based metrics might help identify the largest sources of complaints, while rating impact highlights the issues most damaging to customer satisfaction. So an effective RCA prioritization strategy should consider both dimensions rather than relying solely on complaint volume.

\[ \text{Business Impact}_{p,t} = \text{Share}_{p,t} \times (5 - \text{Rating}_{p,t}) \times \log(1 + \#\text{Reviews}) \]

Weighted Business Impact Score#

Root Cause Prioritization using a Weighted Business Impact Score

The proposed ranking approach is inspired by the Failure Mode and Effects Analysis (FMEA) methodology and uses a weighted business impact score to prioritize root causes.

def business_impact_score(share_pct: float, rating: float, review_count: int) -> float:
    # share_pct - how common, rating - how painfull, review_count - adds sconfidence/stability
    return (share_pct/100) * (5 - rating) * np.log1p(review_count)
\[ \text{BusinessImpact}_{p,t} = \frac{\text{Frequency}_{p,t} + \alpha \pi}{\#\text{Reviews} + \alpha} \cdot \max\left(\epsilon, \frac{5 - \text{Rating}_{p}}{4}\right) \cdot \log\left(1 + \#\text{Reviews}\right) \cdot w_t \]
rca_severity_weight = {
    'health_reaction_issue': 1.8,
    'durability_issue': 1.5,
    'performance_issue': 1.4,
    'ingredient_issue': 1.3,
    'material_quality_issue': 1.2,
    'taste_issue': 1.1,
    'smell_issue': 1.1,
    'size_issue': 1.0,
    'appearance_issue': 0.9,
    'print_quality_issue': 0.9,
    'usability_issue': 1.0,
}

def business_impact_score(
    frequency: int,
    review_count: int,
    rating: float,
    issue_class: str,
    prior_rate: float = 0.15,
    smoothing: int = 10,
) -> tuple[float]:
    issue_rate = (frequency + smoothing * prior_rate) / (review_count + smoothing) #avoids over ranking 1/2 or 2/2 cases
    rating_severity = max(0.15, (5 - rating) / 4) #small floor so rating 5 does not erase issues
    confidence = np.log1p(review_count) #more reviews is more confidence
    class_weight = rca_severity_weight.get(issue_class, 1.0)

    return (issue_rate, rating_severity, issue_rate * rating_severity * confidence * class_weight)
tag_frequency[['issue_rate', 'rating_severity', 'impact_score']] = tag_frequency.apply(
    lambda row: business_impact_score(
        row['frequency'], row['review_count'], row['rating'], row['class'],
    ), axis=1, result_type='expand'
)
tag_frequency.style.background_gradient(subset=['impact_score'], cmap='coolwarm')
tag_frequency[tag_frequency['category']=='books'].sort_values('impact_score', ascending=False).head(10)
product_id name category rating price_kzt tag_lemma frequency review_count share_pct class issue_rate rating_severity impact_score
1550 2779736 Разрушительная любовь books 4.6 4404.0 клише 29 30 96.7 print_quality_issue 0.762500 0.150 0.353486
67 389876 Гарри Поттер и Философский камень books 4.3 7283.0 перевод 22 30 73.3 print_quality_issue 0.587500 0.175 0.317751
215 937477 Богатый папа, бедный папа books 4.4 5816.0 расклеиваться 14 30 46.7 durability_issue 0.387500 0.150 0.299401
82 473259 Кошки и котята books 4.9 2050.0 порода 10 23 43.5 ingredient_issue 0.348485 0.150 0.215963
206 875533 Сказки барда Бидля books 4.8 6250.0 перевод 12 22 54.5 print_quality_issue 0.421875 0.150 0.178576
1553 2784038 Кругом одни идиоты. 4 типа личности. Как найти... books 4.7 2094.0 переплёт 5 11 45.5 durability_issue 0.309524 0.150 0.173056
129 632472 Бухгалтерский учет от азов до баланса. Практич... books 4.5 8226.0 опечатка 13 30 43.3 print_quality_issue 0.362500 0.150 0.168051
662 1493660 Исследование о природе и причинах богатства на... books 4.7 3208.0 переплёт 4 8 50.0 durability_issue 0.305556 0.150 0.151059
378 1256282 Дневник жены юмориста books 4.5 1047.0 простой 10 23 43.5 print_quality_issue 0.348485 0.150 0.149513
59 322768 Золотое универсальное Таро. 78 карт books 4.6 13434.0 позолота стираться 5 21 23.8 durability_issue 0.209677 0.150 0.145827
tag_frequency.style.background_gradient(subset=['impact_score'], cmap='coolwarm')
tag_frequency[tag_frequency['category']=='home'].sort_values('impact_score', ascending=False).head(10)
product_id name category rating price_kzt tag_lemma frequency review_count share_pct class issue_rate rating_severity impact_score
699 1607513 ДМ Текстиль Полотенце махровое home 4.5 5522.0 тонкий 23 30 76.7 material_quality_issue 0.612500 0.15 0.378597
703 1607524 ДМ Текстиль Полотенце махровое home 4.5 3361.0 тонкий 22 29 75.9 material_quality_issue 0.602564 0.15 0.368899
857 1766569 ДМ Текстиль Полотенце махровое home 4.5 4378.0 тонкий 22 30 73.3 material_quality_issue 0.587500 0.15 0.363144
696 1607508 ДМ Текстиль Полотенце махровое home 4.5 1790.0 тонкий 21 28 75.0 material_quality_issue 0.592105 0.15 0.358883
705 1607534 ДМ Текстиль Полотенце махровое home 4.5 1579.0 тонкий 21 28 75.0 material_quality_issue 0.592105 0.15 0.358883
704 1607528 ДМ Текстиль Полотенце махровое home 4.5 1579.0 тонкий 21 29 72.4 material_quality_issue 0.576923 0.15 0.353201
700 1607514 ДМ Текстиль Полотенце махровое home 4.5 1579.0 тонкий 21 29 72.4 material_quality_issue 0.576923 0.15 0.353201
697 1607510 ДМ Текстиль Полотенце махровое home 4.5 4352.0 тонкий 20 27 74.1 material_quality_issue 0.581081 0.15 0.348531
702 1607519 ДМ Текстиль Полотенце махровое home 4.5 2419.0 тонкий 20 27 74.1 material_quality_issue 0.581081 0.15 0.348531
698 1607511 ДМ Текстиль Полотенце махровое home 4.5 5142.0 тонкий 20 27 74.1 material_quality_issue 0.581081 0.15 0.348531
tag_frequency.style.background_gradient(subset=['impact_score'], cmap='coolwarm')
tag_frequency[tag_frequency['category']=='pet'].sort_values('impact_score', ascending=False).head(10)
product_id name category rating price_kzt tag_lemma frequency review_count share_pct class issue_rate rating_severity impact_score
775 1660192 Bonsy БИОошейник антипаразитарный для котят и ... pet 4.1 1026.0 сильный запах 20 24 83.3 smell_issue 0.632353 0.225 0.503778
777 1660198 Bonsy БИОошейник антипаразитарный для котят и ... pet 4.1 1026.0 сильный запах 19 26 73.1 smell_issue 0.569444 0.225 0.464507
145 729892 Пижон Мышь из натурального меха малая, 9×4×4 с... pet 3.7 810.0 маленький 13 28 46.4 size_issue 0.381579 0.325 0.417589
776 1660194 Bonsy БИОошейник антипаразитарный для котят и ... pet 4.1 1026.0 сильный запах 15 23 65.2 smell_issue 0.500000 0.225 0.393284
1334 2433589 Сибирская Кошка Наполнитель для кошачьего туал... pet 3.3 3633.0 не_впитывать запах 6 15 40.0 smell_issue 0.300000 0.425 0.388856
890 1810478 Сибирская Кошка Наполнитель для кошачьего туал... pet 3.3 1725.0 не_впитывать запах 6 15 40.0 smell_issue 0.300000 0.425 0.388856
1600 2914453 Сибирская Кошка Наполнитель для кошачьего туал... pet 3.3 6131.0 не_впитывать запах 6 15 40.0 smell_issue 0.300000 0.425 0.388856
508 1406213 Mr.Fresh Expert Спрей «Защита от царапания» дл... pet 3.3 3274.0 резкий запах 5 8 62.5 smell_issue 0.361111 0.425 0.370934
99 554894 Пижон Колтунорез боковой 9 лезвий pet 1.5 3511.0 боль животный 1 2 50.0 health_reaction_issue 0.208333 0.875 0.360482
257 1105450 Monge Ветеринарный паштет для кошек при струви... pet 1.0 741.0 запах 2 2 100.0 smell_issue 0.291667 1.000 0.352471
tag_frequency.style.background_gradient(subset=['impact_score'], cmap='coolwarm')
tag_frequency[tag_frequency['category']=='beauty'].sort_values('impact_score', ascending=False).head(10)
product_id name category rating price_kzt tag_lemma frequency review_count share_pct class issue_rate rating_severity impact_score
393 1290127 Organic Kitchen Маска-лифтинг для лица «Укол к... beauty 4.6 1281.0 жжение 27 30 90.0 health_reaction_issue 0.712500 0.15 0.660613
253 1101085 Art-Visage Тушь для ресниц суперобъемная «Chic... beauty 4.6 2887.0 осыпаться 29 30 96.7 durability_issue 0.762500 0.15 0.589143
222 989112 Vivienne Sabo Тушь для ресниц с эффектом сцени... beauty 4.6 2530.0 осыпаться 29 30 96.7 durability_issue 0.762500 0.15 0.589143
1182 2279197 Golden Rose Лак для ногтей «Keratin nail color... beauty 4.6 882.0 полосит 28 30 93.3 durability_issue 0.737500 0.15 0.569827
1174 2279097 Golden Rose Лак для ногтей «Keratin nail color... beauty 4.6 882.0 полосит 28 30 93.3 durability_issue 0.737500 0.15 0.569827
1241 2325414 Golden Rose Лак для ногтей «Keratin nail color... beauty 4.6 882.0 полосит 28 30 93.3 durability_issue 0.737500 0.15 0.569827
1175 2279143 Golden Rose Лак для ногтей «Keratin nail color... beauty 4.6 882.0 полосит 28 30 93.3 durability_issue 0.737500 0.15 0.569827
1187 2279209 Golden Rose Лак для ногтей «Keratin nail color... beauty 4.6 882.0 полосит 27 29 93.1 durability_issue 0.730769 0.15 0.559235
1207 2279260 Golden Rose Лак для ногтей «Keratin nail color... beauty 4.6 882.0 полосит 27 29 93.1 durability_issue 0.730769 0.15 0.559235
1192 2279221 Golden Rose Лак для ногтей «Keratin nail color... beauty 4.6 882.0 полосит 27 29 93.1 durability_issue 0.730769 0.15 0.559235
tag_frequency[tag_frequency['category']=='food'].sort_values('impact_score', ascending=False).head(10)
product_id name category rating price_kzt tag_lemma frequency review_count share_pct class issue_rate rating_severity impact_score
954 1879352 Иван да Марья Рожки нутовые без глютена food 4.2 1716.0 развариваться 10 12 83.3 durability_issue 0.522727 0.200 0.402231
953 1879322 Иван да Марья Рожки гороховые без глютена food 4.2 1439.0 развариваться 10 12 83.3 durability_issue 0.522727 0.200 0.402231
1308 2409789 Корона Чернослив food 4.1 778.0 плесень 9 15 60.0 ingredient_issue 0.420000 0.225 0.340613
1907 3893080 Вкусы мира Конфеты «Манго-шарики в какао-глазури» food 4.1 590.0 глазурь 5 12 41.7 health_reaction_issue 0.295455 0.225 0.306920
902 1832160 Bestofindia Чай черный «Масала» food 4.0 1487.0 гранулированный 7 15 46.7 ingredient_issue 0.340000 0.250 0.306371
1695 3119759 Makfa Макароны «Королевские ракушки» food 4.5 545.0 развариваться 10 12 83.3 durability_issue 0.522727 0.150 0.301673
870 1788782 Трапеза Пудра сахарная нетающая шоколадная food 4.4 2158.0 таять 15 23 65.2 material_quality_issue 0.500000 0.150 0.286025
1569 2846656 Foco Кокосовое молоко органическое food 4.6 1049.0 расслоение 12 28 42.9 durability_issue 0.355263 0.150 0.269162
1226 2285577 TopLife Псиллиум food 4.5 4172.0 комковаться 11 23 47.8 performance_issue 0.378788 0.150 0.252800
903 1832160 Bestofindia Чай черный «Масала» food 4.0 1487.0 мало специя 5 15 33.3 ingredient_issue 0.260000 0.250 0.234284
tag_frequency.to_csv('data/tag_ferquency_ranked.csv', index=False)