Download src/eda.py from dahutapea/Topic-Modeling-Tokopedia-Reviews: direct link, hf CLI and curl.
- Browser
- Download file 20.5 kB
-
https://huggingface.co/spaces/dahutapea/Topic-Modeling-Tokopedia-Reviews/resolve/main/src/eda.py
- Command line
-
hf download hf://spaces/dahutapea/Topic-Modeling-Tokopedia-Reviews/src/eda.py
-
curl -L -o eda.py https://huggingface.co/spaces/dahutapea/Topic-Modeling-Tokopedia-Reviews/resolve/main/src/eda.py
20.5 kB
| import os | |
| import pandas as pd | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| import streamlit as st | |
| from utils.paths import ARTIFACTS | |
| POS_COLOR = "#2e7d32" | |
| NEG_COLOR = "#c62828" | |
| ACCENT = "#1565c0" | |
| def load_data(): | |
| return pd.read_csv(os.path.join(ARTIFACTS, "dashboard_data.csv")) | |
| def theme_summary(d): | |
| """How many reviews fall into each theme.""" | |
| if d.empty: | |
| return pd.DataFrame(columns=["theme", "reviews", "pct"]) | |
| s = d.groupby("theme").size().sort_values(ascending=False).rename("reviews").reset_index() | |
| s["pct"] = (100 * s["reviews"] / len(d)).round(1) | |
| return s | |
| def conclusion(text): | |
| """Blue takeaway box shown under each visualization.""" | |
| st.info(f"**Takeaway:** {text}") | |
| # ---------------- Individual analysis sections ---------------- | |
| # Each function renders ONE section. Only the section the user selects is | |
| # executed/rendered, so the page never mounts ~13 Plotly charts at once | |
| # (which is what made the EDA page churn / never settle on the Space). | |
| def section_themes(df, df_pos, df_neg, summ_pos, summ_neg): | |
| st.header("What Customers Praise vs. Complain About") | |
| st.caption("Each bar is a recurring topic found in the reviews, and how often it shows up.") | |
| tcol1, tcol2 = st.columns(2) | |
| with tcol1: | |
| st.subheader("Positive themes") | |
| if summ_pos.empty: | |
| st.info("No positive reviews in the current filter selection.") | |
| else: | |
| fig = px.bar(summ_pos.sort_values("reviews"), x="reviews", y="theme", orientation="h", | |
| text="pct", color_discrete_sequence=[POS_COLOR]) | |
| fig.update_traces(texttemplate="%{text}%", textposition="outside") | |
| fig.update_layout(height=420, margin=dict(l=10, r=10, t=10, b=10), | |
| xaxis_title="Reviews", yaxis_title="", showlegend=False) | |
| st.plotly_chart(fig, use_container_width=True) | |
| conclusion(f"**{summ_pos.iloc[0]['theme']}** is what customers love the most, " | |
| f"making up {summ_pos.iloc[0]['pct']}% of positive reviews.") | |
| with tcol2: | |
| st.subheader("Negative Themes") | |
| if summ_neg.empty: | |
| st.info("No negative reviews in the current filter selection.") | |
| else: | |
| fig = px.bar(summ_neg.sort_values("reviews"), x="reviews", y="theme", orientation="h", | |
| text="pct", color_discrete_sequence=[NEG_COLOR]) | |
| fig.update_traces(texttemplate="%{text}%", textposition="outside") | |
| fig.update_layout(height=420, margin=dict(l=10, r=10, t=10, b=10), | |
| xaxis_title="Reviews", yaxis_title="", showlegend=False) | |
| st.plotly_chart(fig, use_container_width=True) | |
| conclusion(f"**{summ_neg.iloc[0]['theme']}** is the top complaint, making up " | |
| f"{summ_neg.iloc[0]['pct']}% of negative reviews. This is the first thing to fix.") | |
| def section_ratings(df, df_pos, df_neg, summ_pos, summ_neg): | |
| st.header("How Are Customers Actually Rating Their Experience?") | |
| st.caption("A closer look using the actual 1-5 star score, instead of just good/bad.") | |
| rcol1, rcol2 = st.columns([1, 1.3]) | |
| with rcol1: | |
| st.subheader("Star Rating Distribution") | |
| rating_counts = df["Customer Rating"].value_counts().sort_index() | |
| fig = px.bar(x=rating_counts.index, y=rating_counts.values, color=rating_counts.index, | |
| color_continuous_scale=["#c62828", "#ef6c00", "#fbc02d", "#7cb342", "#2e7d32"]) | |
| fig.update_layout(height=380, margin=dict(l=10, r=10, t=10, b=10), | |
| xaxis_title="Star rating", yaxis_title="Reviews", | |
| showlegend=False, coloraxis_showscale=False, xaxis=dict(tickmode="linear")) | |
| st.plotly_chart(fig, use_container_width=True) | |
| most_common_rating = rating_counts.idxmax() | |
| one_star_pct = 100 * rating_counts.get(1, 0) / rating_counts.sum() | |
| conclusion(f"Ratings are split: **{most_common_rating}-star** is the most common score, but " | |
| f"**{one_star_pct:.0f}%** of all reviews are 1-star, showing a clear group of unhappy customers.") | |
| with rcol2: | |
| st.subheader("Star Rating by Category") | |
| cat_rating = df.groupby("Category")["Customer Rating"].agg(["mean", "count"]) | |
| cat_rating = cat_rating[cat_rating["count"] >= 10].sort_values("mean") | |
| fig = px.bar(cat_rating.tail(15), x="mean", orientation="h", color="mean", | |
| color_continuous_scale="RdYlGn", range_color=[1, 5]) | |
| fig.update_layout(height=380, margin=dict(l=10, r=10, t=10, b=10), | |
| xaxis_title="Average star rating", yaxis_title="", | |
| showlegend=False, coloraxis_showscale=False) | |
| st.plotly_chart(fig, use_container_width=True) | |
| best_cat = cat_rating["mean"].idxmax() | |
| worst_cat = cat_rating["mean"].idxmin() | |
| conclusion(f"**{best_cat}** has the happiest customers ({cat_rating.loc[best_cat, 'mean']:.2f}/5), " | |
| f"while **{worst_cat}** has the lowest average rating ({cat_rating.loc[worst_cat, 'mean']:.2f}/5).") | |
| def section_emotions(df, df_pos, df_neg, summ_pos, summ_neg): | |
| st.header("How Do These Themes Make Customers Feel?") | |
| st.caption("Each row adds up to 100%, showing which emotion dominates that topic.") | |
| ecol1, ecol2 = st.columns(2) | |
| with ecol1: | |
| st.subheader("Positive Themes by Emotion") | |
| d = df_pos[df_pos["theme"] != "Other"] | |
| if d.empty: | |
| st.info("No data for the current filters.") | |
| else: | |
| ct = pd.crosstab(d["theme"], d["Emotion"]) | |
| ct_pct = ct.div(ct.sum(axis=1), axis=0) * 100 | |
| ct_pct = ct_pct.loc[d["theme"].value_counts().index] | |
| fig = px.imshow(ct_pct, text_auto=".0f", aspect="auto", color_continuous_scale="Greens", | |
| labels=dict(color="% within theme")) | |
| fig.update_layout(height=400, margin=dict(l=10, r=10, t=10, b=10)) | |
| st.plotly_chart(fig, use_container_width=True) | |
| conclusion(f"**{ct_pct.mean().idxmax()}** is the dominant emotion across most positive themes - " | |
| f"customers aren't just satisfied, they're emotionally engaged.") | |
| with ecol2: | |
| st.subheader("Negative Themes by Emotion") | |
| d = df_neg[df_neg["theme"] != "Other"] | |
| if d.empty: | |
| st.info("No data for the current filters.") | |
| else: | |
| ct = pd.crosstab(d["theme"], d["Emotion"]) | |
| ct_pct = ct.div(ct.sum(axis=1), axis=0) * 100 | |
| ct_pct = ct_pct.loc[d["theme"].value_counts().index] | |
| fig = px.imshow(ct_pct, text_auto=".0f", aspect="auto", color_continuous_scale="Reds", | |
| labels=dict(color="% within theme")) | |
| fig.update_layout(height=400, margin=dict(l=10, r=10, t=10, b=10)) | |
| st.plotly_chart(fig, use_container_width=True) | |
| conclusion(f"**{ct_pct.mean().idxmax()}** dominates negative reviews; knowing the emotion helps " | |
| f"decide how customer service should respond (calm anger vs. reassure fear).") | |
| def section_price(df, df_pos, df_neg, summ_pos, summ_neg): | |
| st.header("Does Price Predict Satisfaction?") | |
| st.caption("Products are split into 4 equal-sized price groups, from cheapest to most expensive.") | |
| pcol1, pcol2 = st.columns(2) | |
| df_price = df.copy() | |
| try: | |
| df_price["price_bracket"] = pd.qcut(df_price["Price"], q=4, | |
| labels=["Budget", "Mid-low", "Mid-high", "Premium"], | |
| duplicates="drop") | |
| except ValueError: | |
| df_price["price_bracket"] = pd.cut(df_price["Price"], bins=4) | |
| with pcol1: | |
| st.subheader("Rating Distribution by Price Bracket") | |
| fig = px.box(df_price, x="price_bracket", y="Customer Rating", color="price_bracket", | |
| category_orders={"price_bracket": ["Budget", "Mid-low", "Mid-high", "Premium"]}) | |
| fig.update_layout(height=400, margin=dict(l=10, r=10, t=10, b=10), | |
| xaxis_title="Price bracket", yaxis_title="Customer rating", showlegend=False) | |
| st.plotly_chart(fig, use_container_width=True) | |
| median_by_bracket = df_price.groupby("price_bracket", observed=True)["Customer Rating"].median() | |
| cheapest_median = median_by_bracket.get("Budget", median_by_bracket.iloc[0]) | |
| priciest_median = median_by_bracket.get("Premium", median_by_bracket.iloc[-1]) | |
| if priciest_median > cheapest_median: | |
| conclusion(f"The typical (median) rating rises with price: **Budget** items median " | |
| f"{cheapest_median:.0f}/5 vs. **Premium** items at {priciest_median:.0f}/5.") | |
| else: | |
| conclusion(f"Median ratings don't rise consistently with price: **Budget** sits at " | |
| f"{cheapest_median:.0f}/5 and **Premium** at {priciest_median:.0f}/5.") | |
| with pcol2: | |
| st.subheader("Negative Review Rate by Price Bracket") | |
| neg_rate = (df_price.assign(is_neg=df_price["Sentiment"].eq("Negative")) | |
| .groupby("price_bracket", observed=True)["is_neg"].mean().mul(100)) | |
| fig = px.bar(neg_rate, color_discrete_sequence=[NEG_COLOR], | |
| category_orders={"price_bracket": ["Budget", "Mid-low", "Mid-high", "Premium"]}) | |
| fig.update_layout(height=400, margin=dict(l=10, r=10, t=10, b=10), | |
| xaxis_title="Price bracket", yaxis_title="% negative reviews", showlegend=False) | |
| st.plotly_chart(fig, use_container_width=True) | |
| cheapest_rate = neg_rate.get("Budget", neg_rate.iloc[0]) | |
| priciest_rate = neg_rate.get("Premium", neg_rate.iloc[-1]) | |
| if cheapest_rate > priciest_rate: | |
| conclusion(f"Cheaper products complain more: **Budget** items have a {cheapest_rate:.0f}% negative " | |
| f"rate vs. only {priciest_rate:.0f}% for **Premium**. Price may signal quality expectations.") | |
| else: | |
| conclusion(f"Price doesn't clearly predict complaints: **Premium** items actually have a higher " | |
| f"negative rate ({priciest_rate:.0f}%) than **Budget** items ({cheapest_rate:.0f}%).") | |
| def section_geography(df, df_pos, df_neg, summ_pos, summ_neg): | |
| st.header("Where Are Customers Reviewing From?") | |
| st.caption("Top 15 cities by number of reviews, and how each one feels about their purchase.") | |
| gcol1, gcol2 = st.columns(2) | |
| top_locations = df["Location"].value_counts().head(15) | |
| with gcol1: | |
| st.subheader("Review Volume by Location") | |
| fig = px.bar(top_locations.sort_values(), orientation="h", color_discrete_sequence=[ACCENT]) | |
| fig.update_layout(height=450, margin=dict(l=10, r=10, t=10, b=10), | |
| xaxis_title="Reviews", yaxis_title="", showlegend=False) | |
| st.plotly_chart(fig, use_container_width=True) | |
| conclusion(f"**{top_locations.idxmax()}** sends in the most reviews ({top_locations.max():,}), " | |
| f"making it the biggest customer base worth prioritizing.") | |
| with gcol2: | |
| st.subheader("Negative Review Rate by Location") | |
| loc_counts = df.groupby("Location").size() | |
| loc_neg_rate = (df.assign(is_neg=df["Sentiment"].eq("Negative")) | |
| .groupby("Location")["is_neg"].mean().mul(100)) | |
| loc_neg_rate = loc_neg_rate[loc_counts[loc_neg_rate.index] >= 10] | |
| loc_neg_rate = loc_neg_rate.loc[loc_neg_rate.index.intersection(top_locations.index)] | |
| fig = px.bar(loc_neg_rate.sort_values(), orientation="h", color_discrete_sequence=[NEG_COLOR]) | |
| fig.add_vline(x=loc_neg_rate.mean(), line_dash="dash", line_color="gray") | |
| fig.update_layout(height=450, margin=dict(l=10, r=10, t=10, b=10), | |
| xaxis_title="% negative reviews", yaxis_title="", showlegend=False) | |
| st.plotly_chart(fig, use_container_width=True) | |
| conclusion(f"**{loc_neg_rate.idxmax()}** has the highest share of negative reviews " | |
| f"({loc_neg_rate.max():.0f}%), possibly pointing to local delivery or logistics issues.") | |
| def section_category(df, df_pos, df_neg, summ_pos, summ_neg): | |
| st.header("Which Categories Sell a Lot but Satisfy Little?") | |
| st.caption("Each bubble is a category. Bigger bubble = more reviews.") | |
| cat_perf = df.groupby("Category").agg( | |
| avg_sold=("Number Sold", "mean"), | |
| avg_rating=("Overall Rating", "mean"), | |
| review_count=("Customer Rating", "count"), | |
| ).reset_index() | |
| cat_perf = cat_perf[cat_perf["review_count"] >= 10] | |
| fig = px.scatter(cat_perf, x="avg_sold", y="avg_rating", size="review_count", color="avg_rating", | |
| color_continuous_scale="RdYlGn", range_color=[4, 5], hover_name="Category", size_max=45) | |
| fig.update_layout(height=480, margin=dict(l=10, r=10, t=10, b=10), | |
| xaxis_title="Average units sold", yaxis_title="Average overall rating", | |
| coloraxis_showscale=False) | |
| st.plotly_chart(fig, use_container_width=True) | |
| risk_cat = cat_perf.sort_values(["avg_sold", "avg_rating"], ascending=[False, True]).iloc[0] | |
| conclusion(f"**{risk_cat['Category']}** sells the most but isn't the highest rated " | |
| f"({risk_cat['avg_rating']:.2f}/5 average) - worth a closer look since it affects the most customers.") | |
| def section_priority(df, df_pos, df_neg, summ_pos, summ_neg): | |
| st.header("Which Complaints Should The Business Fix First?") | |
| st.caption("X-axis = how often it happens. Y-axis = how angry/scared it makes customers. Top-right = fix first.") | |
| d = df_neg[df_neg["theme"] != "Other"] | |
| if d.empty: | |
| st.info("No negative reviews for the current filters.") | |
| else: | |
| em = pd.crosstab(d["theme"], d["Emotion"]) | |
| for col in ["Anger", "Fear"]: | |
| if col not in em.columns: | |
| em[col] = 0 | |
| em_pct = em.div(em.sum(axis=1), axis=0) * 100 | |
| prio = pd.DataFrame({"volume": d["theme"].value_counts(), | |
| "urgency": em_pct["Anger"] + em_pct["Fear"]}) | |
| fig = go.Figure() | |
| fig.add_trace(go.Scatter(x=prio["volume"], y=prio["urgency"], mode="markers+text", | |
| text=prio.index, textposition="top center", | |
| marker=dict(size=14, color=NEG_COLOR), textfont=dict(size=10))) | |
| fig.update_layout(height=500, margin=dict(l=10, r=10, t=10, b=10), | |
| xaxis_title="Volume (number of reviews)", | |
| yaxis_title="Urgency (% Anger + Fear)") | |
| st.plotly_chart(fig, use_container_width=True) | |
| top_priority = (prio["volume"].rank(pct=True) + prio["urgency"].rank(pct=True)).idxmax() | |
| conclusion(f"**{top_priority}** scores highest on both volume and urgency - the single biggest " | |
| f"opportunity to reduce complaints.") | |
| def section_products(df, df_pos, df_neg, summ_pos, summ_neg): | |
| st.header("Best and Worst Rated Products") | |
| st.caption("Only products with 5+ reviews are shown, so one angry buyer can't skew the list.") | |
| prod_stats = df.groupby(["Product Name", "Category"]).agg( | |
| avg_rating=("Customer Rating", "mean"), | |
| reviews=("Customer Rating", "count"), | |
| price=("Price", "mean"), | |
| ).reset_index() | |
| prod_stats = prod_stats[prod_stats["reviews"] >= 5] | |
| bcol1, bcol2 = st.columns(2) | |
| with bcol1: | |
| st.subheader("Top Rated") | |
| top_prod = prod_stats.sort_values(["avg_rating", "reviews"], ascending=[False, False]).head(10) | |
| st.dataframe(top_prod.assign(avg_rating=top_prod["avg_rating"].round(2)).reset_index(drop=True), | |
| use_container_width=True, height=380) | |
| with bcol2: | |
| st.subheader("Lowest Rated") | |
| bottom_prod = prod_stats.sort_values(["avg_rating", "reviews"], ascending=[True, False]).head(10) | |
| st.dataframe(bottom_prod.assign(avg_rating=bottom_prod["avg_rating"].round(2)).reset_index(drop=True), | |
| use_container_width=True, height=380) | |
| if not bottom_prod.empty: | |
| conclusion(f"The lowest-rated qualifying product is **{bottom_prod.iloc[0]['Product Name']}** " | |
| f"at {bottom_prod.iloc[0]['avg_rating']:.2f}/5 - a candidate for delisting or seller follow-up.") | |
| def section_explorer(df, df_pos, df_neg, summ_pos, summ_neg): | |
| st.header("Review Explorer") | |
| st.caption("Pick a theme or search a keyword to read the actual reviews behind the numbers.") | |
| sel_theme = st.multiselect("Filter by theme", sorted(df["theme"].unique()), default=[]) | |
| explorer_df = df.copy() | |
| if sel_theme: | |
| explorer_df = explorer_df[explorer_df["theme"].isin(sel_theme)] | |
| search = st.text_input("Search review text (optional)", "") | |
| if search: | |
| explorer_df = explorer_df[explorer_df["Customer Review"].str.contains(search, case=False, na=False)] | |
| st.dataframe( | |
| explorer_df[["Category", "Product Name", "Sentiment", "Emotion", "theme", "Customer Review"]] | |
| .reset_index(drop=True), | |
| use_container_width=True, height=400, | |
| ) | |
| st.caption(f"Showing {len(explorer_df):,} of {len(df):,} filtered reviews.") | |
| # Ordered registry of sections: label -> render function. | |
| SECTIONS = { | |
| "Praise vs Complaints": section_themes, | |
| "Star Ratings": section_ratings, | |
| "Emotions by Theme": section_emotions, | |
| "Price vs Satisfaction": section_price, | |
| "Geography": section_geography, | |
| "Category Performance": section_category, | |
| "Priority Matrix": section_priority, | |
| "Best & Worst Products": section_products, | |
| "Review Explorer": section_explorer, | |
| } | |
| def run(): | |
| reviews = load_data() | |
| ALL_CATEGORIES = sorted(reviews["Category"].unique()) | |
| ALL_SENTIMENTS = sorted(reviews["Sentiment"].unique()) | |
| ALL_EMOTIONS = sorted(reviews["Emotion"].unique()) | |
| # ---------------- Sidebar filters ---------------- | |
| st.sidebar.title("Filters") | |
| st.sidebar.caption("Filters apply to every section below.") | |
| sel_sentiment = st.sidebar.multiselect("Sentiment", ALL_SENTIMENTS, default=ALL_SENTIMENTS) | |
| sel_category = st.sidebar.multiselect("Product category", ALL_CATEGORIES, default=[]) | |
| sel_emotion = st.sidebar.multiselect("Emotion", ALL_EMOTIONS, default=[]) | |
| df = reviews.copy() | |
| if sel_sentiment: | |
| df = df[df["Sentiment"].isin(sel_sentiment)] | |
| if sel_category: | |
| df = df[df["Category"].isin(sel_category)] | |
| if sel_emotion: | |
| df = df[df["Emotion"].isin(sel_emotion)] | |
| if df.empty: | |
| st.warning("No reviews match the current filters. Try widening your selection in the sidebar.") | |
| st.stop() | |
| df_pos = df[df["Sentiment"] == "Positive"] | |
| df_neg = df[df["Sentiment"] == "Negative"] | |
| summ_pos = theme_summary(df_pos[df_pos["theme"] != "Other"]) | |
| summ_neg = theme_summary(df_neg[df_neg["theme"] != "Other"]) | |
| # ---------------- Header ---------------- | |
| st.title("Tokopedia Review Insights") | |
| st.caption("What do customers actually praise and complain about, and what should the business do about it?") | |
| # ---------------- KPI row (always shown, lightweight) ---------------- | |
| total = len(df) | |
| pct_pos = 100 * len(df_pos) / total if total else 0 | |
| pct_neg = 100 * len(df_neg) / total if total else 0 | |
| top_complaint = summ_neg.iloc[0]["theme"] if not summ_neg.empty else "-" | |
| c1, c2, c3, c4, c5 = st.columns(5) | |
| c1.metric("Total reviews", f"{total:,}") | |
| c2.metric("Positive", f"{len(df_pos):,}", f"{pct_pos:.0f}% of total") | |
| c3.metric("Negative", f"{len(df_neg):,}", f"{pct_neg:.0f}% of total", delta_color="inverse") | |
| c4.metric("Avg. customer rating", f"{df['Customer Rating'].mean():.2f} / 5") | |
| c5.metric("Top complaint", top_complaint if len(top_complaint) < 22 else top_complaint[:20] + "...") | |
| st.divider() | |
| # ---------------- Section selector ---------------- | |
| # Only the chosen section's charts are built/rendered. This keeps the page | |
| # light enough to settle reliably on the Space (instead of churning while it | |
| # tries to mount every chart at once). | |
| choice = st.radio("Choose an analysis", list(SECTIONS.keys()), horizontal=True) | |
| st.divider() | |
| SECTIONS[choice](df, df_pos, df_neg, summ_pos, summ_neg) | |
| st.divider() | |
| st.caption("Topic Pulse | Tokopedia Review Insight - FTDS-040-HCK Group 001") | |