[{"data":1,"prerenderedAt":1886},["ShallowReactive",2],{"lang-switch-post-\u002Fen\u002Fprojects\u002Folist-ecommerce":3,"project-en-olist-ecommerce":4,"project-chapters-en-olist-ecommerce":97},null,{"id":5,"title":6,"body":7,"cover":3,"description":82,"extension":83,"meta":84,"navigation":85,"order":86,"path":87,"seo":88,"status":89,"stem":90,"tags":91,"__hash__":96},"projects\u002Fen\u002Fprojects\u002Folist-ecommerce\u002Findex.md","Olist: an End-to-End Brazilian E-Commerce Case Study",{"type":8,"value":9,"toc":77},"minimark",[10,19,30,35,38,66,69],[11,12,13,14,18],"p",{},"This project is different from any playlist here. A playlist is a class, one concept at a time, each post independent from the last. This is an ",[15,16,17],"strong",{},"end-to-end data science case study",": I take a real, messy dataset with nine tables tied together, and go all the way through, EDA, visualization, feature engineering, model training, interpretability, and a business conclusion. The chapters build on each other, chapter 3 assumes you've already read chapters 1 and 2.",[11,20,21,22,29],{},"The dataset is the ",[23,24,28],"a",{"href":25,"rel":26},"https:\u002F\u002Fwww.kaggle.com\u002Fdatasets\u002Folistbr\u002Fbrazilian-ecommerce",[27],"nofollow","Brazilian E-Commerce Public Dataset, by Olist",", available on Kaggle. Real (anonymized) data from over 100 thousand orders placed between 2016 and 2018 on an actual Brazilian marketplace, customer, seller, product, payment, review, all tied together by keys. All the heavy processing (pandas, scikit-learn, XGBoost, SHAP) runs in a notebook I execute on Google Colab, with a T4 GPU when a chapter needs one. The real results of those notebooks become the interactive charts here on the blog, never an invented number.",[31,32,34],"h2",{"id":33},"the-four-scenarios","The four scenarios",[11,36,37],{},"Defined right in the first chapter, because they steer the whole project:",[39,40,41,48,54,60],"ol",{},[42,43,44,47],"li",{},[15,45,46],{},"Delivery delay prediction"," (binary classification): does the order arrive after the estimated date or not?",[42,49,50,53],{},[15,51,52],{},"Review score prediction"," (classification\u002Fregression): how many stars will the customer give?",[42,55,56,59],{},[15,57,58],{},"Freight or order value prediction"," (regression): how much will it cost?",[42,61,62,65],{},[15,63,64],{},"Customer segmentation"," (clustering, RFM): what kinds of customers exist in this base?",[11,67,68],{},"Each pulls a different slice of the same dataframe, and the goal is to show you how the same base dataset serves very different business questions.",[11,70,71,72,76],{},"Start with ",[23,73,75],{"href":74},"\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F01-intro-relational-model-eda","Chapter 1: relational model and first exploration",".",{"title":78,"searchDepth":79,"depth":79,"links":80},"",2,[81],{"id":33,"depth":79,"text":34},"A real data science case study on the Brazilian marketplace Olist. From raw CSV to trained model, covering EDA, feature engineering, training four different models, and interpretability.","md",{},true,1,"\u002Fen\u002Fprojects\u002Folist-ecommerce",{"title":6,"description":82},"published","en\u002Fprojects\u002Folist-ecommerce\u002Findex",[92,93,94,95],"pandas","scikit-learn","xgboost","shap","Zyg5at_SeJfe3mUFl8y1jpFOae7KlXUDgPBPFUpuapU",[98,553,1048,1447],{"id":99,"title":100,"body":101,"cover":3,"date":544,"description":545,"extension":83,"meta":546,"navigation":85,"order":86,"path":74,"project":547,"seo":548,"status":89,"stem":549,"tags":550,"__hash__":552},"projectChapters\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F01-intro-relational-model-eda.md","Nine Tables, One Business: Olist's Relational Model",{"type":8,"value":102,"toc":536},[103,116,120,182,185,224,228,231,351,356,360,376,438,457,461,464,507,511,514,518,525,529,532],[11,104,105,106,110,111,76],{},"Before training any model, I need to understand the shape of the data. And the Olist dataset isn't a single table, it's a genuine mini relational database: nine CSVs, each representing a business entity (order, item, payment, review, customer, seller, product), tied together by keys. I ran all of this in the ",[107,108,109],"code",{},"01_intro_eda.ipynb"," notebook, right inside the dataset's own directory, and left it public on Colab: ",[23,112,115],{"href":113,"rel":114},"https:\u002F\u002Fcolab.research.google.com\u002Fdrive\u002F1kQvo5YjlLPiODqGhlAGZNk84ikg-PZg7?usp=sharing",[27],"check out the full notebook here",[31,117,119],{"id":118},"the-relational-model","The relational model",[121,122,126],"pre",{"className":123,"code":124,"language":125,"meta":78,"style":78},"language-python shiki shiki-themes github-light github-dark","orders = pd.read_csv('olist_orders_dataset.csv')\norder_items = pd.read_csv('olist_order_items_dataset.csv')\npayments = pd.read_csv('olist_order_payments_dataset.csv')\nreviews = pd.read_csv('olist_order_reviews_dataset.csv')\ncustomers = pd.read_csv('olist_customers_dataset.csv')\nsellers = pd.read_csv('olist_sellers_dataset.csv')\nproducts = pd.read_csv('olist_products_dataset.csv')\ngeolocation = pd.read_csv('olist_geolocation_dataset.csv')\ncategory_translation = pd.read_csv('product_category_name_translation.csv')\n","python",[107,127,128,135,140,146,152,158,164,170,176],{"__ignoreMap":78},[129,130,132],"span",{"class":131,"line":86},"line",[129,133,134],{},"orders = pd.read_csv('olist_orders_dataset.csv')\n",[129,136,137],{"class":131,"line":79},[129,138,139],{},"order_items = pd.read_csv('olist_order_items_dataset.csv')\n",[129,141,143],{"class":131,"line":142},3,[129,144,145],{},"payments = pd.read_csv('olist_order_payments_dataset.csv')\n",[129,147,149],{"class":131,"line":148},4,[129,150,151],{},"reviews = pd.read_csv('olist_order_reviews_dataset.csv')\n",[129,153,155],{"class":131,"line":154},5,[129,156,157],{},"customers = pd.read_csv('olist_customers_dataset.csv')\n",[129,159,161],{"class":131,"line":160},6,[129,162,163],{},"sellers = pd.read_csv('olist_sellers_dataset.csv')\n",[129,165,167],{"class":131,"line":166},7,[129,168,169],{},"products = pd.read_csv('olist_products_dataset.csv')\n",[129,171,173],{"class":131,"line":172},8,[129,174,175],{},"geolocation = pd.read_csv('olist_geolocation_dataset.csv')\n",[129,177,179],{"class":131,"line":178},9,[129,180,181],{},"category_translation = pd.read_csv('product_category_name_translation.csv')\n",[183,184],"olist-relational-model",{},[11,186,187,190,191,194,195,194,198,201,202,205,206,194,209,194,212,215,216,219,220,223],{},[107,188,189],{},"order_id"," is the key stitching almost everything together: it shows up in ",[107,192,193],{},"orders",", ",[107,196,197],{},"order_items",[107,199,200],{},"payments",", and ",[107,203,204],{},"reviews",". ",[107,207,208],{},"customer_id",[107,210,211],{},"product_id",[107,213,214],{},"seller_id",", and the zip code prefix close out the rest of the links. One detail worth flagging already: ",[107,217,218],{},"olist_order_reviews_dataset.csv"," has 104,719 raw lines of text in the file, but pandas only recognizes ",[15,221,222],{},"99,224 real records"," when reading the CSV. The difference is because a lot of review comments contain literal line breaks inside the text field (the customer wrote across several paragraphs), and pandas' parser correctly counts that as one single record, while counting raw file lines overestimates. A good reminder that \"number of lines in the file\" and \"number of records\" aren't always the same thing in a CSV with free-text fields.",[31,225,227],{"id":226},"data-quality-nulls-have-a-story","Data quality: nulls have a story",[11,229,230],{},"Not every null is a problem, sometimes it's information. Across the tables where nulls show up:",[232,233,234,255],"table",{},[235,236,237],"thead",{},[238,239,240,245,248,252],"tr",{},[241,242,244],"th",{"align":243},"left","Table",[241,246,247],{"align":243},"Column",[241,249,251],{"align":250},"right","Nulls",[241,253,254],{"align":250},"%",[256,257,258,272,285,298,311,324,338],"tbody",{},[238,259,260,263,266,269],{},[261,262,193],"td",{"align":243},[261,264,265],{"align":243},"order_approved_at",[261,267,268],{"align":250},"160",[261,270,271],{"align":250},"0.2%",[238,273,274,276,279,282],{},[261,275,193],{"align":243},[261,277,278],{"align":243},"order_delivered_carrier_date",[261,280,281],{"align":250},"1,783",[261,283,284],{"align":250},"1.8%",[238,286,287,289,292,295],{},[261,288,193],{"align":243},[261,290,291],{"align":243},"order_delivered_customer_date",[261,293,294],{"align":250},"2,965",[261,296,297],{"align":250},"3.0%",[238,299,300,302,305,308],{},[261,301,204],{"align":243},[261,303,304],{"align":243},"review_comment_title",[261,306,307],{"align":250},"87,656",[261,309,310],{"align":250},"88.3%",[238,312,313,315,318,321],{},[261,314,204],{"align":243},[261,316,317],{"align":243},"review_comment_message",[261,319,320],{"align":250},"58,247",[261,322,323],{"align":250},"58.7%",[238,325,326,329,332,335],{},[261,327,328],{"align":243},"products",[261,330,331],{"align":243},"product_category_name (+ 3 other product columns)",[261,333,334],{"align":250},"610",[261,336,337],{"align":250},"1.9%",[238,339,340,342,345,348],{},[261,341,328],{"align":243},[261,343,344],{"align":243},"weight\u002Fdimensions (4 columns)",[261,346,347],{"align":250},"2",[261,349,350],{"align":250},"0.0%",[11,352,353,355],{},[107,354,291],{}," being null on 3% of orders isn't a capture error, it's an order that never arrived (canceled, lost, or still in transit when the dataset was frozen). That becomes an important feature down the line, when I build the delay scenario: an order with no delivery date has no way to compute a delay, so those 2,965 orders need explicit handling (excluded from the delay analysis, or treated as their own category), I can't just fill them with zero or the mean. Reviews missing a title or comment (88% and 59% of cases) aren't a problem either, most customers just leave the star rating and write nothing, completely normal e-commerce review behavior.",[31,357,359],{"id":358},"building-the-master-dataframe","Building the master dataframe",[11,361,362,363,366,367,369,370,372,373,375],{},"The dataset's natural granularity is ",[15,364,365],{},"order item",", not whole order: one ",[107,368,189],{}," can have several items, from different sellers, each with its own price and freight. That's why the merge starts from ",[107,371,197],{},", not ",[107,374,193],{},":",[121,377,379],{"className":123,"code":378,"language":125,"meta":78,"style":78},"produtos_com_categoria_en = products.merge(category_translation, on='product_category_name', how='left')\n\nmestre = (\n    order_items\n    .merge(orders, on='order_id', how='left')\n    .merge(customers, on='customer_id', how='left')\n    .merge(produtos_com_categoria_en, on='product_id', how='left')\n    .merge(sellers, on='seller_id', how='left')\n    .merge(payments, on='order_id', how='left')\n    .merge(reviews, on='order_id', how='left')\n)\n",[107,380,381,386,391,396,401,406,411,416,421,426,432],{"__ignoreMap":78},[129,382,383],{"class":131,"line":86},[129,384,385],{},"produtos_com_categoria_en = products.merge(category_translation, on='product_category_name', how='left')\n",[129,387,388],{"class":131,"line":79},[129,389,390],{"emptyLinePlaceholder":85},"\n",[129,392,393],{"class":131,"line":142},[129,394,395],{},"mestre = (\n",[129,397,398],{"class":131,"line":148},[129,399,400],{},"    order_items\n",[129,402,403],{"class":131,"line":154},[129,404,405],{},"    .merge(orders, on='order_id', how='left')\n",[129,407,408],{"class":131,"line":160},[129,409,410],{},"    .merge(customers, on='customer_id', how='left')\n",[129,412,413],{"class":131,"line":166},[129,414,415],{},"    .merge(produtos_com_categoria_en, on='product_id', how='left')\n",[129,417,418],{"class":131,"line":172},[129,419,420],{},"    .merge(sellers, on='seller_id', how='left')\n",[129,422,423],{"class":131,"line":178},[129,424,425],{},"    .merge(payments, on='order_id', how='left')\n",[129,427,429],{"class":131,"line":428},10,[129,430,431],{},"    .merge(reviews, on='order_id', how='left')\n",[129,433,435],{"class":131,"line":434},11,[129,436,437],{},")\n",[11,439,440,442,443,446,447,449,450,452,453,456],{},[107,441,197],{}," alone has 112,650 rows. After all the merges, the master dataframe has ",[15,444,445],{},"118,310 rows",", more than the starting point. That's not a bug: when an order is paid across several installments recorded as separate rows in ",[107,448,200],{},", or gets more than one entry in ",[107,451,204],{},", the merge multiplies that order-item row for each combination. It's expected relational-merge behavior, but it's exactly the kind of thing that, if I don't check the ",[107,454,455],{},"shape"," before and after, slips by unnoticed and inflates counts in any later aggregation.",[31,458,460],{"id":459},"the-four-ml-scenarios","The four ML scenarios",[11,462,463],{},"With the master dataframe in hand, defined for the chapters ahead:",[39,465,466,478,488,502],{},[42,467,468,471,472,474,475,76],{},[15,469,470],{},"Delivery delay"," (binary classification): compare ",[107,473,291],{}," against ",[107,476,477],{},"order_estimated_delivery_date",[42,479,480,483,484,487],{},[15,481,482],{},"Review score"," (multiclass classification or regression): ",[107,485,486],{},"review_score",", from 1 to 5.",[42,489,490,493,494,497,498,501],{},[15,491,492],{},"Freight or order value"," (regression): ",[107,495,496],{},"freight_value",", or the sum of ",[107,499,500],{},"price"," per order.",[42,503,504,506],{},[15,505,64],{}," (clustering, no target): Recency, Frequency, and Monetary value per customer, the RFM technique.",[31,508,510],{"id":509},"orders-per-month-the-black-friday-spike","Orders per month: the Black Friday spike",[11,512,513],{},"A first temporal look, counting unique orders by purchase month:",[515,516],"olist-orders-per-month-chart",{"x-label":517,"y-label":193},"month",[11,519,520,521,524],{},"September 2016 starts with only 4 orders (Olist had barely launched), volume grows month over month through 2017, and November 2017 jumps sharply to ",[15,522,523],{},"7,544 orders",", against 4,631 in October and 5,673 in December of the same year. That's Black Friday, an isolated spike that breaks the smooth growth trend, and it's going to matter when I get to seasonality in the feature-engineering chapters. September and October 2018 show up with only 16 and 4 orders, a sign the dataset was frozen mid-month, not that sales collapsed.",[31,526,528],{"id":527},"wrapping-up-the-chapter","Wrapping up the chapter",[11,530,531],{},"Relational model mapped, master dataframe assembled (118,310 rows), data quality checked (the delivery and review nulls have real explanations, not errors), and the four scenarios defined. Next chapter I get into the real visualization work: a geographic map of orders and delay by state, category distribution, payment method, and the relationship between delay and review score, which already gives an \"aha moment\" before training a single model.",[533,534,535],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":78,"searchDepth":79,"depth":79,"links":537},[538,539,540,541,542,543],{"id":118,"depth":79,"text":119},{"id":226,"depth":79,"text":227},{"id":358,"depth":79,"text":359},{"id":459,"depth":79,"text":460},{"id":509,"depth":79,"text":510},{"id":527,"depth":79,"text":528},"2026-08-24","First chapter of the Olist case study: I load the nine CSVs, build the master dataframe, check real data quality, and define the four ML scenarios the rest of the project will cover.",{},"olist-ecommerce",{"title":100,"description":545},"en\u002Fprojects\u002Folist-ecommerce\u002F01-intro-relational-model-eda",[92,551],"eda","M6y2XePQUxjrKgwzJwkI_8pqI8zOp2HhATq5VmgyFiU",{"id":554,"title":555,"body":556,"cover":3,"date":544,"description":1040,"extension":83,"meta":1041,"navigation":85,"order":79,"path":1042,"project":547,"seo":1043,"status":89,"stem":1044,"tags":1045,"__hash__":1047},"projectChapters\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F02-visualizacoes-exploratorias.md","Brazil Shops on Weekdays: Olist's Exploratory Storytelling",{"type":8,"value":557,"toc":1031},[558,566,570,628,633,636,640,715,718,725,729,777,782,796,800,842,845,852,856,894,899,902,905,952,957,964,968,1006,1010,1024,1026,1029],[11,559,560,561,76],{},"Chapter 1 mapped the relational model and built the master dataframe. Now I let the data speak visually, six different angles on the same business, before training a single model. Full executed notebook, ",[23,562,565],{"href":563,"rel":564},"https:\u002F\u002Fcolab.research.google.com\u002Fdrive\u002F1PrCuoWoAnb5paNi5dxzbSlxAGrcyA_mk?usp=sharing",[27],"public on Colab",[31,567,569],{"id":568},"temporal-brazil-shops-on-weekdays","Temporal: Brazil shops on weekdays",[121,571,573],{"className":123,"code":572,"language":125,"meta":78,"style":78},"orders_com_hora = orders.dropna(subset=['order_purchase_timestamp']).copy()\norders_com_hora['weekday'] = orders_com_hora['order_purchase_timestamp'].dt.weekday\norders_com_hora['hour'] = orders_com_hora['order_purchase_timestamp'].dt.hour\n\nweekday_hour = (\n    orders_com_hora\n    .groupby(['weekday', 'hour'])['order_id']\n    .nunique()\n    .reset_index()\n    .rename(columns={'order_id': 'orders'})\n)\n",[107,574,575,580,585,590,594,599,604,609,614,619,624],{"__ignoreMap":78},[129,576,577],{"class":131,"line":86},[129,578,579],{},"orders_com_hora = orders.dropna(subset=['order_purchase_timestamp']).copy()\n",[129,581,582],{"class":131,"line":79},[129,583,584],{},"orders_com_hora['weekday'] = orders_com_hora['order_purchase_timestamp'].dt.weekday\n",[129,586,587],{"class":131,"line":142},[129,588,589],{},"orders_com_hora['hour'] = orders_com_hora['order_purchase_timestamp'].dt.hour\n",[129,591,592],{"class":131,"line":148},[129,593,390],{"emptyLinePlaceholder":85},[129,595,596],{"class":131,"line":154},[129,597,598],{},"weekday_hour = (\n",[129,600,601],{"class":131,"line":160},[129,602,603],{},"    orders_com_hora\n",[129,605,606],{"class":131,"line":166},[129,607,608],{},"    .groupby(['weekday', 'hour'])['order_id']\n",[129,610,611],{"class":131,"line":172},[129,612,613],{},"    .nunique()\n",[129,615,616],{"class":131,"line":178},[129,617,618],{},"    .reset_index()\n",[129,620,621],{"class":131,"line":428},[129,622,623],{},"    .rename(columns={'order_id': 'orders'})\n",[129,625,626],{"class":131,"line":434},[129,627,437],{},[629,630],"olist-weekday-hour-heatmap",{":weekday-labels":631,"y-label":632},"[\"Mon\",\"Tue\",\"Wed\",\"Thu\",\"Fri\",\"Sat\",\"Sun\"]","day of week",[11,634,635],{},"168 cells (7 days × 24 hours), the sum matches the total order count exactly, always worth checking before trusting the chart. The peak is Tuesday at 2pm, with 1,124 orders in a single hour. Looking at totals by weekday, Monday through Friday each land between 14 and 16 thousand orders, while Saturday drops to 10,887 and Sunday to 11,960, almost a 30% dip. By hour, lunchtime and early afternoon (11am, 1pm-4pm) hold the bulk of activity, and the early morning hours (3am-5am) practically flatline, under 300 orders in each of those hours across the entire dataset. That's the classic \"shopping from work or on a break\" pattern, not late-night leisure browsing.",[31,637,639],{"id":638},"geographic-the-north-waits-longer","Geographic: the North waits longer",[121,641,643],{"className":123,"code":642,"language":125,"meta":78,"style":78},"orders_com_cliente = orders.merge(customers, on='customer_id', how='left')\n\nfrete_por_pedido = order_items.groupby('order_id')['freight_value'].sum().reset_index()\norders_com_cliente = orders_com_cliente.merge(frete_por_pedido, on='order_id', how='left')\n\norders_com_cliente['delivery_days'] = (\n    orders_com_cliente['order_delivered_customer_date'] - orders_com_cliente['order_purchase_timestamp']\n).dt.days\n\npor_estado = orders_com_cliente.groupby('customer_state').agg(\n    orders=('order_id', 'nunique'),\n    avg_freight=('freight_value', 'mean'),\n    avg_delivery_days=('delivery_days', 'mean'),\n).reset_index()\n",[107,644,645,650,654,659,664,668,673,678,683,687,692,697,703,709],{"__ignoreMap":78},[129,646,647],{"class":131,"line":86},[129,648,649],{},"orders_com_cliente = orders.merge(customers, on='customer_id', how='left')\n",[129,651,652],{"class":131,"line":79},[129,653,390],{"emptyLinePlaceholder":85},[129,655,656],{"class":131,"line":142},[129,657,658],{},"frete_por_pedido = order_items.groupby('order_id')['freight_value'].sum().reset_index()\n",[129,660,661],{"class":131,"line":148},[129,662,663],{},"orders_com_cliente = orders_com_cliente.merge(frete_por_pedido, on='order_id', how='left')\n",[129,665,666],{"class":131,"line":154},[129,667,390],{"emptyLinePlaceholder":85},[129,669,670],{"class":131,"line":160},[129,671,672],{},"orders_com_cliente['delivery_days'] = (\n",[129,674,675],{"class":131,"line":166},[129,676,677],{},"    orders_com_cliente['order_delivered_customer_date'] - orders_com_cliente['order_purchase_timestamp']\n",[129,679,680],{"class":131,"line":172},[129,681,682],{},").dt.days\n",[129,684,685],{"class":131,"line":178},[129,686,390],{"emptyLinePlaceholder":85},[129,688,689],{"class":131,"line":428},[129,690,691],{},"por_estado = orders_com_cliente.groupby('customer_state').agg(\n",[129,693,694],{"class":131,"line":434},[129,695,696],{},"    orders=('order_id', 'nunique'),\n",[129,698,700],{"class":131,"line":699},12,[129,701,702],{},"    avg_freight=('freight_value', 'mean'),\n",[129,704,706],{"class":131,"line":705},13,[129,707,708],{},"    avg_delivery_days=('delivery_days', 'mean'),\n",[129,710,712],{"class":131,"line":711},14,[129,713,714],{},").reset_index()\n",[716,717],"olist-state-choropleth",{},[11,719,720,721,724],{},"The map shows real average delivery time (purchase to delivery) by customer state. ",[15,722,723],{},"Roraima leads with almost 29 days of average wait"," (28.98), followed by Amapá (26.73), Amazonas (25.99), and Alagoas (24.04). It's no coincidence those states top the list: Roraima, Amapá, and Amazonas are three of the states farthest from the Southeast's industrial and logistics hub, where most Olist sellers are concentrated. São Paulo, for comparison, sits much closer to the fast end of this distribution. That distance-to-delivery-time relationship is a natural candidate for a strong feature once I build the delay prediction scenario.",[31,726,728],{"id":727},"category-health-and-beauty-leads-revenue","Category: health and beauty leads revenue",[121,730,732],{"className":123,"code":731,"language":125,"meta":78,"style":78},"top_categorias = (\n    mestre\n    .dropna(subset=['product_category_name_english'])\n    .groupby('product_category_name_english')['price']\n    .sum()\n    .sort_values(ascending=False)\n    .head(15)\n    .reset_index()\n)\n",[107,733,734,739,744,749,754,759,764,769,773],{"__ignoreMap":78},[129,735,736],{"class":131,"line":86},[129,737,738],{},"top_categorias = (\n",[129,740,741],{"class":131,"line":79},[129,742,743],{},"    mestre\n",[129,745,746],{"class":131,"line":142},[129,747,748],{},"    .dropna(subset=['product_category_name_english'])\n",[129,750,751],{"class":131,"line":148},[129,752,753],{},"    .groupby('product_category_name_english')['price']\n",[129,755,756],{"class":131,"line":154},[129,757,758],{},"    .sum()\n",[129,760,761],{"class":131,"line":160},[129,762,763],{},"    .sort_values(ascending=False)\n",[129,765,766],{"class":131,"line":166},[129,767,768],{},"    .head(15)\n",[129,770,771],{"class":131,"line":172},[129,772,618],{},[129,774,775],{"class":131,"line":178},[129,776,437],{},[778,779],"olist-top-categories-chart",{"x-label":780,"y-label":781},"category","revenue (US$)",[11,783,784,787,788,791,792,795],{},[107,785,786],{},"health_beauty"," leads with 1,301,947.97 dollars in combined revenue, closely followed by ",[107,789,790],{},"watches_gifts"," (1,254,322.95) and ",[107,793,794],{},"bed_bath_table"," (1,107,249.09). This is revenue, not freight or item count, so a category can lead either by selling expensive items (watches) or by selling a lot of them (bed\u002Fbath\u002Ftable items get replaced often).",[31,797,799],{"id":798},"payment-credit-card-dominates","Payment: credit card dominates",[121,801,803],{"className":123,"code":802,"language":125,"meta":78,"style":78},"tipos_pagamento = (\n    payments[payments['payment_type'] != 'not_defined']\n    .groupby('payment_type')['order_id']\n    .nunique()\n    .reset_index()\n    .rename(columns={'order_id': 'count'})\n    .sort_values('count', ascending=False)\n)\n",[107,804,805,810,815,820,824,828,833,838],{"__ignoreMap":78},[129,806,807],{"class":131,"line":86},[129,808,809],{},"tipos_pagamento = (\n",[129,811,812],{"class":131,"line":79},[129,813,814],{},"    payments[payments['payment_type'] != 'not_defined']\n",[129,816,817],{"class":131,"line":142},[129,818,819],{},"    .groupby('payment_type')['order_id']\n",[129,821,822],{"class":131,"line":148},[129,823,613],{},[129,825,826],{"class":131,"line":154},[129,827,618],{},[129,829,830],{"class":131,"line":160},[129,831,832],{},"    .rename(columns={'order_id': 'count'})\n",[129,834,835],{"class":131,"line":166},[129,836,837],{},"    .sort_values('count', ascending=False)\n",[129,839,840],{"class":131,"line":172},[129,841,437],{},[843,844],"olist-payment-types-chart",{},[11,846,847,848,851],{},"Credit card dominates by a wide margin: 76,505 orders, over 3 times the runner-up (boleto, a Brazilian bank-slip payment method, 19,784). Voucher comes in at 3,866 and debit card at only 1,528. I dropped the ",[107,849,850],{},"not_defined"," category from the chart, it only had 3 rows out of over 100 thousand payments, pure noise, not worth a pie slice.",[31,853,855],{"id":854},"satisfaction-most-love-it-but-the-haters-are-vocal","Satisfaction: most love it, but the haters are vocal",[121,857,859],{"className":123,"code":858,"language":125,"meta":78,"style":78},"distribuicao_notas = (\n    reviews\n    .groupby('review_score')['review_id']\n    .count()\n    .reset_index()\n    .rename(columns={'review_id': 'count'})\n)\n",[107,860,861,866,871,876,881,885,890],{"__ignoreMap":78},[129,862,863],{"class":131,"line":86},[129,864,865],{},"distribuicao_notas = (\n",[129,867,868],{"class":131,"line":79},[129,869,870],{},"    reviews\n",[129,872,873],{"class":131,"line":142},[129,874,875],{},"    .groupby('review_score')['review_id']\n",[129,877,878],{"class":131,"line":148},[129,879,880],{},"    .count()\n",[129,882,883],{"class":131,"line":154},[129,884,618],{},[129,886,887],{"class":131,"line":160},[129,888,889],{},"    .rename(columns={'review_id': 'count'})\n",[129,891,892],{"class":131,"line":166},[129,893,437],{},[895,896],"olist-review-score-chart",{"x-label":897,"y-label":898},"score","number of reviews",[11,900,901],{},"57,328 five-star reviews, over half the total, with four-star coming in second at 19,142. But look at third place: one-star reviews, at 11,424, alone outnumber two-star (3,151) and three-star (8,179) combined. That's the classic e-commerce review pattern: a satisfied customer sometimes doesn't bother reviewing at all, a neutral customer almost never does, and a very unhappy customer always shows up to vent. A distribution shaped like this (a big peak at 5, a smaller second peak at 1) is already a warning for when I build the score prediction scenario later: it's not a well-behaved continuous scale, it's closer to a \"loved it or hated it\" decision with a rare middle ground.",[11,903,904],{},"Alongside that, a real sample (400 orders) crossing delivery delay against the score given:",[121,906,908],{"className":123,"code":907,"language":125,"meta":78,"style":78},"nota_atraso = (\n    orders[['order_id', 'order_delivered_customer_date', 'order_estimated_delivery_date']]\n    .merge(reviews[['order_id', 'review_score']], on='order_id', how='inner')\n    .dropna(subset=['order_delivered_customer_date', 'order_estimated_delivery_date', 'review_score'])\n)\n\nnota_atraso['delay_days'] = (\n    nota_atraso['order_delivered_customer_date'] - nota_atraso['order_estimated_delivery_date']\n).dt.days\n",[107,909,910,915,920,925,930,934,938,943,948],{"__ignoreMap":78},[129,911,912],{"class":131,"line":86},[129,913,914],{},"nota_atraso = (\n",[129,916,917],{"class":131,"line":79},[129,918,919],{},"    orders[['order_id', 'order_delivered_customer_date', 'order_estimated_delivery_date']]\n",[129,921,922],{"class":131,"line":142},[129,923,924],{},"    .merge(reviews[['order_id', 'review_score']], on='order_id', how='inner')\n",[129,926,927],{"class":131,"line":148},[129,928,929],{},"    .dropna(subset=['order_delivered_customer_date', 'order_estimated_delivery_date', 'review_score'])\n",[129,931,932],{"class":131,"line":154},[129,933,437],{},[129,935,936],{"class":131,"line":160},[129,937,390],{"emptyLinePlaceholder":85},[129,939,940],{"class":131,"line":166},[129,941,942],{},"nota_atraso['delay_days'] = (\n",[129,944,945],{"class":131,"line":172},[129,946,947],{},"    nota_atraso['order_delivered_customer_date'] - nota_atraso['order_estimated_delivery_date']\n",[129,949,950],{"class":131,"line":178},[129,951,682],{},[953,954],"olist-review-delay-scatter",{"x-label":955,"y-label":956},"delay (days, negative = early)","review score",[11,958,959,960,963],{},"A negative ",[107,961,962],{},"delay_days"," means the order arrived early, positive means a real delay. Notice how the one-star points (the bottom row) spread across the whole axis, including into negative territory, but with a visibly heavier concentration on the delay side (right) than the five-star points show.",[31,965,967],{"id":966},"correlation-the-punchline-before-the-model","Correlation: the punchline before the model",[121,969,971],{"className":123,"code":970,"language":125,"meta":78,"style":78},"features_numericas = mestre[['price', 'freight_value', 'product_weight_g', 'payment_value', 'review_score']].copy()\nfeatures_numericas['delivery_days'] = (\n    mestre['order_delivered_customer_date'] - mestre['order_purchase_timestamp']\n).dt.days\nfeatures_numericas = features_numericas.dropna()\n\nmatriz_corr = features_numericas.corr()\n",[107,972,973,978,983,988,992,997,1001],{"__ignoreMap":78},[129,974,975],{"class":131,"line":86},[129,976,977],{},"features_numericas = mestre[['price', 'freight_value', 'product_weight_g', 'payment_value', 'review_score']].copy()\n",[129,979,980],{"class":131,"line":79},[129,981,982],{},"features_numericas['delivery_days'] = (\n",[129,984,985],{"class":131,"line":142},[129,986,987],{},"    mestre['order_delivered_customer_date'] - mestre['order_purchase_timestamp']\n",[129,989,990],{"class":131,"line":148},[129,991,682],{},[129,993,994],{"class":131,"line":154},[129,995,996],{},"features_numericas = features_numericas.dropna()\n",[129,998,999],{"class":131,"line":160},[129,1000,390],{"emptyLinePlaceholder":85},[129,1002,1003],{"class":131,"line":166},[129,1004,1005],{},"matriz_corr = features_numericas.corr()\n",[1007,1008],"olist-correlation-heatmap",{":feature-labels":1009},"{\"price\":\"price\",\"freight_value\":\"freight\",\"product_weight_g\":\"weight\",\"payment_value\":\"payment\",\"review_score\":\"review score\",\"delivery_days\":\"delivery days\"}",[11,1011,1012,1013,1015,1016,1019,1020,1023],{},"Here's the number I promised back in Chapter 1's closing: ",[107,1014,486],{}," correlates at ",[15,1017,1018],{},"-0.30"," with ",[107,1021,1022],{},"delivery_days",", computed over 114,838 rows of the master dataframe. That's the strongest relationship any numeric feature has with the review score (price, freight, and product weight all sit below 0.08 in absolute value). It's not a huge correlation (nowhere near -1), but it's clearly the standout of the group, and it lines up exactly with what the geographic map already hinted at: slow delivery looks like the single factor most associated with dragging customer satisfaction down, more than what the product cost or weighed.",[31,1025,528],{"id":527},[11,1027,1028],{},"Six angles, one pattern emerging: delivery time shows up three separate times (map, scatter, correlation) as the strongest thread for explaining dissatisfaction. Credit card dominates payment, health and beauty dominates revenue, Tuesday afternoon is peak shopping time. Next chapter I move into feature engineering and selection for the four scenarios defined in Chapter 1, and the data-leakage discussion for the delay scenario is going to land a lot more concretely with these geographic and correlation numbers already in hand.",[533,1030,535],{},{"title":78,"searchDepth":79,"depth":79,"links":1032},[1033,1034,1035,1036,1037,1038,1039],{"id":568,"depth":79,"text":569},{"id":638,"depth":79,"text":639},{"id":727,"depth":79,"text":728},{"id":798,"depth":79,"text":799},{"id":854,"depth":79,"text":855},{"id":966,"depth":79,"text":967},{"id":527,"depth":79,"text":528},"Second chapter of the Olist case study: a purchase-time heatmap, a delay choropleth by state, top revenue categories, payment method, and the correlation that delivers the punchline before any model gets trained: delay kills the review score.",{},"\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F02-visualizacoes-exploratorias",{"title":555,"description":1040},"en\u002Fprojects\u002Folist-ecommerce\u002F02-visualizacoes-exploratorias",[92,551,1046],"visualization","BnrLxILdDrkWpuFL2BtwKuLZUU_h4b6ecFEa5Q28zb4",{"id":1049,"title":1050,"body":1051,"cover":3,"date":544,"description":1438,"extension":83,"meta":1439,"navigation":85,"order":142,"path":1440,"project":547,"seo":1441,"status":89,"stem":1442,"tags":1443,"__hash__":1446},"projectChapters\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F03-features-e-selecao.md","Data Leakage: the Easiest Way to Fool Yourself",{"type":8,"value":1052,"toc":1430},[1053,1060,1064,1071,1120,1135,1139,1170,1185,1188,1193,1208,1211,1216,1227,1231,1239,1248,1308,1316,1320,1323,1377,1380,1384,1387,1416,1423,1425,1428],[11,1054,1055,1056,76],{},"Chapters 1 and 2 built the master dataframe and showed six exploratory angles. Now I need to pick, for each of the four scenarios defined back in Chapter 1, which features actually make sense to use. Full executed notebook, ",[23,1057,565],{"href":1058,"rel":1059},"https:\u002F\u002Fcolab.research.google.com\u002Fdrive\u002F1nt9wVC9_2H646HEKG-XTo6P4cvx-zPPr?usp=sharing",[27],[31,1061,1063],{"id":1062},"a-new-feature-first-customer-seller-distance","A new feature first: customer-seller distance",[11,1065,1066,1067,1070],{},"Chapter 2 showed that customer state correlates with delivery time. Before diving into the scenarios, I computed a real distance, not just \"same state or not\": average latitude\u002Flongitude per zip code prefix (",[107,1068,1069],{},"olist_geolocation_dataset",", over a million rows, hence averaging first), and the Haversine formula between customer and seller.",[121,1072,1074],{"className":123,"code":1073,"language":125,"meta":78,"style":78},"geo_media = geolocation.groupby('geolocation_zip_code_prefix')[['geolocation_lat', 'geolocation_lng']].mean().reset_index()\n\ndef haversine(lat1, lon1, lat2, lon2):\n    R = 6371\n    phi1, phi2 = np.radians(lat1), np.radians(lat2)\n    dphi = np.radians(lat2 - lat1)\n    dlambda = np.radians(lon2 - lon1)\n    a = np.sin(dphi \u002F 2) ** 2 + np.cos(phi1) * np.cos(phi2) * np.sin(dlambda \u002F 2) ** 2\n    return 2 * R * np.arcsin(np.sqrt(a))\n",[107,1075,1076,1081,1085,1090,1095,1100,1105,1110,1115],{"__ignoreMap":78},[129,1077,1078],{"class":131,"line":86},[129,1079,1080],{},"geo_media = geolocation.groupby('geolocation_zip_code_prefix')[['geolocation_lat', 'geolocation_lng']].mean().reset_index()\n",[129,1082,1083],{"class":131,"line":79},[129,1084,390],{"emptyLinePlaceholder":85},[129,1086,1087],{"class":131,"line":142},[129,1088,1089],{},"def haversine(lat1, lon1, lat2, lon2):\n",[129,1091,1092],{"class":131,"line":148},[129,1093,1094],{},"    R = 6371\n",[129,1096,1097],{"class":131,"line":154},[129,1098,1099],{},"    phi1, phi2 = np.radians(lat1), np.radians(lat2)\n",[129,1101,1102],{"class":131,"line":160},[129,1103,1104],{},"    dphi = np.radians(lat2 - lat1)\n",[129,1106,1107],{"class":131,"line":166},[129,1108,1109],{},"    dlambda = np.radians(lon2 - lon1)\n",[129,1111,1112],{"class":131,"line":172},[129,1113,1114],{},"    a = np.sin(dphi \u002F 2) ** 2 + np.cos(phi1) * np.cos(phi2) * np.sin(dlambda \u002F 2) ** 2\n",[129,1116,1117],{"class":131,"line":178},[129,1118,1119],{},"    return 2 * R * np.arcsin(np.sqrt(a))\n",[11,1121,1122,1123,1126,1127,1130,1131,1134],{},"Found a real bug at this step before publishing: on the first run, the merge between ",[107,1124,1125],{},"customer_zip_code_prefix","\u002F",[107,1128,1129],{},"seller_zip_code_prefix"," and the geolocation table lost 70% of the data (only 30% coverage), because the zip-code column's data type wasn't guaranteed to match on both sides across every environment. Forced explicit ",[107,1132,1133],{},"int64"," on all three before merging, and coverage went back to what it should be: only 581 orders out of 118,310 ended up without a distance (99.5% coverage). Average customer-seller distance across all of Brazil is 597 km, with a max of 8,678 km, a number that proves just how huge the country is.",[31,1136,1138],{"id":1137},"scenario-1-delivery-delay-and-the-data-leakage-demonstration","Scenario 1: delivery delay, and the data leakage demonstration",[11,1140,1141,1142,194,1145,194,1148,194,1150,1152,1153,1156,1157,1160,1161,474,1163,1165,1166,1169],{},"Candidates: ",[107,1143,1144],{},"distance_km",[107,1146,1147],{},"product_weight_g",[107,1149,500],{},[107,1151,496],{},", approval time (",[107,1154,1155],{},"approval_hours","), purchase month. The ",[107,1158,1159],{},"atrasado"," target is defined by comparing ",[107,1162,291],{},[107,1164,477],{},". And this is where the classic trap of every ML course lives: if I include ",[107,1167,1168],{},"atraso_dias"," (the actual difference between those two dates) as a feature, the model isn't learning to predict delay, it's reading the answer straight off the exam.",[121,1171,1173],{"className":123,"code":1172,"language":125,"meta":78,"style":78},"features_honestas = ['distance_km', 'product_weight_g', 'price', 'freight_value', 'approval_hours', 'month']\nfeatures_vazadas = features_honestas + ['atraso_dias']\n",[107,1174,1175,1180],{"__ignoreMap":78},[129,1176,1177],{"class":131,"line":86},[129,1178,1179],{},"features_honestas = ['distance_km', 'product_weight_g', 'price', 'freight_value', 'approval_hours', 'month']\n",[129,1181,1182],{"class":131,"line":79},[129,1183,1184],{},"features_vazadas = features_honestas + ['atraso_dias']\n",[11,1186,1187],{},"Trained both, a plain RandomForest, same seed, same train\u002Ftest split:",[1189,1190],"olist-leakage-comparison-chart",{"x-label":1191,"y-label":1192},"model","AUC",[11,1194,1195,1196,1199,1200,1203,1204,1207],{},"The honest model lands at ",[15,1197,1198],{},"AUC 0.7388"," (95,968 orders used, real delay rate of 6.76%). The leaky model lands at ",[15,1201,1202],{},"AUC 1.0",", perfect, because ",[107,1205,1206],{},"atraso_dias > 0"," is practically the target's own definition. One detail worth calling out: the honest model's raw accuracy (93.24%) looks great at first glance, but since only 6.76% of orders are late, a dumb model that always guesses \"on time\" already scores 93.24% without learning anything. Accuracy alone is misleading with classes this imbalanced, AUC is the metric that tells the real story here.",[11,1209,1210],{},"Also worth noting, the honest model's feature importance:",[1212,1213],"olist-feature-importance-chart",{"x-label":1214,"y-label":1215},"feature","importance",[11,1217,1218,1223,1224,1226],{},[15,1219,1220,1222],{},[107,1221,517],{}," leads by a wide margin, at 0.36 importance",", practically double the runner-up (",[107,1225,1144],{},", 0.18). This ties directly back to Chapter 2's finding: November (Black Friday) strains logistics in a way that physical distance alone can't explain. Seasonality matters more for predicting delay than how far the seller is from the customer.",[31,1228,1230],{"id":1229},"scenario-2-review-score","Scenario 2: review score",[11,1232,1141,1233,1235,1236,1238],{},[107,1234,1168],{},", delivery time, price, freight, number of installments. Since ",[107,1237,486],{}," is a bimodal ordinal scale (Chapter 2 already showed this), I used mutual information instead of plain Pearson correlation:",[121,1240,1242],{"className":123,"code":1241,"language":125,"meta":78,"style":78},"mi = mutual_info_classif(X2, y2, random_state=42)\n",[107,1243,1244],{"__ignoreMap":78},[129,1245,1246],{"class":131,"line":86},[129,1247,1241],{},[232,1249,1250,1260],{},[235,1251,1252],{},[238,1253,1254,1257],{},[241,1255,1256],{"align":243},"Feature",[241,1258,1259],{"align":250},"Mutual information",[256,1261,1262,1271,1280,1289,1298],{},[238,1263,1264,1268],{},[261,1265,1266],{"align":243},[107,1267,1168],{},[261,1269,1270],{"align":250},"0.0684",[238,1272,1273,1277],{},[261,1274,1275],{"align":243},[107,1276,1022],{},[261,1278,1279],{"align":250},"0.0571",[238,1281,1282,1286],{},[261,1283,1284],{"align":243},[107,1285,496],{},[261,1287,1288],{"align":250},"0.0077",[238,1290,1291,1295],{},[261,1292,1293],{"align":243},[107,1294,500],{},[261,1296,1297],{"align":250},"0.0074",[238,1299,1300,1305],{},[261,1301,1302],{"align":243},[107,1303,1304],{},"payment_installments",[261,1306,1307],{"align":250},"0.0026",[11,1309,1310,1312,1313,1315],{},[107,1311,1168],{}," and ",[107,1314,1022],{}," dominate, almost 10 times more informative than price or freight (95,829 orders used). Confirms, with a different technique, exactly what Chapter 2's correlation already pointed at.",[31,1317,1319],{"id":1318},"scenario-3-freight-value","Scenario 3: freight value",[11,1321,1322],{},"Candidates: weight and the product's three dimensions. Plain Pearson correlation is enough here (98,650 orders used):",[232,1324,1325,1336],{},[235,1326,1327],{},[238,1328,1329,1331],{},[241,1330,1256],{"align":243},[241,1332,1333,1334],{"align":250},"Correlation with ",[107,1335,496],{},[256,1337,1338,1347,1357,1367],{},[238,1339,1340,1344],{},[261,1341,1342],{"align":243},[107,1343,1147],{},[261,1345,1346],{"align":250},"0.615",[238,1348,1349,1354],{},[261,1350,1351],{"align":243},[107,1352,1353],{},"product_height_cm",[261,1355,1356],{"align":250},"0.393",[238,1358,1359,1364],{},[261,1360,1361],{"align":243},[107,1362,1363],{},"product_width_cm",[261,1365,1366],{"align":250},"0.331",[238,1368,1369,1374],{},[261,1370,1371],{"align":243},[107,1372,1373],{},"product_length_cm",[261,1375,1376],{"align":250},"0.317",[11,1378,1379],{},"Weight moderately dominates over any single dimension, which makes physical sense: carriers charge by volumetric weight, but actual weight pulls the bill harder than any one side of the box alone.",[31,1381,1383],{"id":1382},"scenario-4-customer-segmentation-rfm","Scenario 4: customer segmentation (RFM)",[11,1385,1386],{},"This isn't feature selection in the supervised sense, it's engineering the three variables that will feed Chapter 5's clustering: Recency, Frequency, and Monetary value per customer.",[121,1388,1390],{"className":123,"code":1389,"language":125,"meta":78,"style":78},"rfm = oc.groupby('customer_unique_id').agg(\n    recencia_dias=('order_purchase_timestamp', lambda x: (data_max - x.max()).days),\n    frequencia=('order_id', 'nunique'),\n    valor_monetario=('payment_value', 'sum'),\n).reset_index()\n",[107,1391,1392,1397,1402,1407,1412],{"__ignoreMap":78},[129,1393,1394],{"class":131,"line":86},[129,1395,1396],{},"rfm = oc.groupby('customer_unique_id').agg(\n",[129,1398,1399],{"class":131,"line":79},[129,1400,1401],{},"    recencia_dias=('order_purchase_timestamp', lambda x: (data_max - x.max()).days),\n",[129,1403,1404],{"class":131,"line":142},[129,1405,1406],{},"    frequencia=('order_id', 'nunique'),\n",[129,1408,1409],{"class":131,"line":148},[129,1410,1411],{},"    valor_monetario=('payment_value', 'sum'),\n",[129,1413,1414],{"class":131,"line":154},[129,1415,714],{},[11,1417,1418,1419,1422],{},"95,560 unique customers. And the number that stood out the most: ",[15,1420,1421],{},"only 2,924 customers (3.06% of the total) placed more than one order",". Average frequency is 1.034, meaning the entire marketplace is dominated by one-time purchases. That completely changes how to think about segmentation in Chapter 5: there won't be a large \"loyal customer\" category, RFM here will likely separate more by amount spent and recency than by purchase frequency.",[31,1424,528],{"id":527},[11,1426,1427],{},"Customer-seller distance computed (and a real merge bug fixed along the way), data leakage demonstrated in practice (the gap between honest AUC 0.74 and leaky AUC 1.0 is the most concrete lesson I can give on the subject), and each scenario's features picked with a technique suited to its problem type. Seasonality turned out to matter more than distance for delay, delay dominates the review score, weight dominates freight, and the customer base is overwhelmingly one-time buyers. Next chapter I train the real models for each scenario, with experiment tracking and metric comparison.",[533,1429,535],{},{"title":78,"searchDepth":79,"depth":79,"links":1431},[1432,1433,1434,1435,1436,1437],{"id":1062,"depth":79,"text":1063},{"id":1137,"depth":79,"text":1138},{"id":1229,"depth":79,"text":1230},{"id":1318,"depth":79,"text":1319},{"id":1382,"depth":79,"text":1383},{"id":527,"depth":79,"text":528},"Third chapter of the Olist case study: feature engineering and selection for the four scenarios, with a live demonstration of data leakage (a model with 100% AUC that's useless) and a real discovery about seasonality.",{},"\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F03-features-e-selecao",{"title":1050,"description":1438},"en\u002Fprojects\u002Folist-ecommerce\u002F03-features-e-selecao",[93,1444,1445],"feature-engineering","data-leakage","hjJ-zgsW6213GQNSY5CTAs34DrjJoa-kQKoLaduH66U",{"id":1448,"title":1449,"body":1450,"cover":3,"date":544,"description":1877,"extension":83,"meta":1878,"navigation":85,"order":148,"path":1879,"project":547,"seo":1880,"status":89,"stem":1881,"tags":1882,"__hash__":1885},"projectChapters\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F04-treinamento-e-tracking.md","Good AUC, Zero F1: the Default Threshold Trap",{"type":8,"value":1451,"toc":1870},[1452,1469,1473,1476,1561,1566,1586,1594,1597,1601,1604,1661,1665,1677,1681,1687,1740,1744,1747,1751,1763,1856,1862,1865,1867],[11,1453,1454,1455,1460,1461,1464,1465,76],{},"Chapter 3 picked the features for each of the four scenarios. Now it's time to actually train the models, compare them against a baseline, and log every attempt with ",[23,1456,1459],{"href":1457,"rel":1458},"https:\u002F\u002Fmlflow.org\u002F",[27],"MLflow"," (parameters and metrics per run, kept local only, ",[107,1462,1463],{},"mlruns\u002F",", never uploaded anywhere, it's a traceability good practice, not something that turns into a chart here on the blog). Full executed notebook, ",[23,1466,565],{"href":1467,"rel":1468},"https:\u002F\u002Fcolab.research.google.com\u002Fdrive\u002F1dJzFOogCIDK69Avoo4LcW1QUtYbVpsa-?usp=sharing",[27],[31,1470,1472],{"id":1471},"scenario-1-delivery-delay-and-the-default-threshold-trap","Scenario 1: delivery delay, and the default threshold trap",[11,1474,1475],{},"Baseline (always guesses \"on time\") → Logistic Regression → Random Forest → XGBoost, on Chapter 3's honest features (95,968 orders, real delay rate of 6.76%):",[232,1477,1478,1496],{},[235,1479,1480],{},[238,1481,1482,1485,1487,1490,1493],{},[241,1483,1484],{"align":243},"Model",[241,1486,1192],{"align":250},[241,1488,1489],{"align":250},"F1",[241,1491,1492],{"align":250},"Precision",[241,1494,1495],{"align":250},"Recall",[256,1497,1498,1513,1530,1544],{},[238,1499,1500,1503,1506,1509,1511],{},[261,1501,1502],{"align":243},"Baseline",[261,1504,1505],{"align":250},"0.5000",[261,1507,1508],{"align":250},"0.0000",[261,1510,1508],{"align":250},[261,1512,1508],{"align":250},[238,1514,1515,1518,1521,1524,1527],{},[261,1516,1517],{"align":243},"Logistic Regression",[261,1519,1520],{"align":250},"0.6064",[261,1522,1523],{"align":250},"0.0031",[261,1525,1526],{"align":250},"1.0000",[261,1528,1529],{"align":250},"0.0015",[238,1531,1532,1535,1538,1540,1542],{},[261,1533,1534],{"align":243},"Random Forest",[261,1536,1537],{"align":250},"0.7388",[261,1539,1508],{"align":250},[261,1541,1508],{"align":250},[261,1543,1508],{"align":250},[238,1545,1546,1549,1552,1555,1558],{},[261,1547,1548],{"align":243},"XGBoost",[261,1550,1551],{"align":250},"0.7261",[261,1553,1554],{"align":250},"0.0268",[261,1556,1557],{"align":250},"0.4000",[261,1559,1560],{"align":250},"0.0139",[1562,1563],"olist-model-comparison-chart",{"color":1564,"scenario":1565,"x-label":1191,"y-label":1192},"#0033cc","delay",[11,1567,1568,1569,1572,1573,1576,1577,1581,1582,1585],{},"Something odd jumps out of that table: Random Forest has the ",[15,1570,1571],{},"best AUC of the bunch (0.7388)",", but F1, precision and recall are all ",[15,1574,1575],{},"zero",". Not a bug, it's just the mechanics of it. AUC measures whether the model can ",[1578,1579,1580],"em",{},"rank"," risky orders above safe ones, comparing pairs of examples without ever committing to a decision, and Random Forest is genuinely good at that. F1, precision and recall, on the other hand, depend on a binary call: above 0.5 probability the model shouts \"this one's late!\", below it stays quiet. With only 6.76% of orders actually late, Random Forest learned the majority class so well it ",[15,1583,1584],{},"never"," crosses 0.5 probability for any order, not even the ones that really are late. It knows how to rank risk, it just never \"bets\" on the positive class.",[11,1587,1588,1589,1593],{},"Precision here means: of the orders the model flagged as \"will be late\", how many actually were. Recall is the flip side: of the orders that actually were late, how many did the model catch. F1 is the harmonic mean of the two, a way to summarize \"the model is good on both ends\" into one number. Same trio I already used back in the Pattern Recognition playlist, on ",[23,1590,1592],{"href":1591},"\u002Fplaylists\u002Fpattern-recognition\u002Fcredit-card-fraud","credit card fraud detection",", another badly imbalanced classification problem, and the symptom looks the same: a ranking metric (AUC) looks great, a decision metric (F1) exposes that the model, as it stands, can't decide anything on its own.",[11,1595,1596],{},"XGBoost, despite a slightly lower AUC (0.7261), is the only one that actually \"bets\": F1 0.0268, precision 0.40 (when it flags \"will be late\", it's right 4 out of 10 times), recall 0.0139 (but it only catches 1.4% of real delays). None of the four is production-ready as published here. The real next step would be tuning the decision threshold instead of blindly using 0.5, or balancing the class during training, but that's a topic for another day, this chapter is about comparing models, not tuning decision thresholds.",[31,1598,1600],{"id":1599},"scenario-2-review-score-regression","Scenario 2: review score (regression)",[11,1602,1603],{},"Baseline (always guesses the mean) → Linear Regression → Random Forest → XGBoost, on Chapter 3's features (95,829 orders):",[232,1605,1606,1618],{},[235,1607,1608],{},[238,1609,1610,1612,1615],{},[241,1611,1484],{"align":243},[241,1613,1614],{"align":250},"R²",[241,1616,1617],{"align":250},"MAE",[256,1619,1620,1630,1641,1651],{},[238,1621,1622,1624,1627],{},[261,1623,1502],{"align":243},[261,1625,1626],{"align":250},"-0.0001",[261,1628,1629],{"align":250},"0.9950",[238,1631,1632,1635,1638],{},[261,1633,1634],{"align":243},"Linear Regression",[261,1636,1637],{"align":250},"0.1232",[261,1639,1640],{"align":250},"0.9228",[238,1642,1643,1645,1648],{},[261,1644,1534],{"align":243},[261,1646,1647],{"align":250},"0.2028",[261,1649,1650],{"align":250},"0.8765",[238,1652,1653,1655,1658],{},[261,1654,1548],{"align":243},[261,1656,1657],{"align":250},"0.1861",[261,1659,1660],{"align":250},"0.8794",[1562,1662],{"color":1663,"scenario":1664,"x-label":1191,"y-label":1614},"#cc3300","review",[11,1666,1667,1668,1671,1672,1312,1674,1676],{},"R² measures how much of the score's variation the model explains (1.0 would be a perfect prediction, 0 would be \"always guess the mean\"). MAE is the average error in stars, how far off the model lands, on average, from the real score. Random Forest edges out XGBoost here (0.2028 vs. 0.1861), the opposite of what you'd usually expect from those two. But the more honest number is how low the ceiling is for both: ",[15,1669,1670],{},"not even the best model clears 20% of explained variance",". That matches what Chapter 3 already found using mutual information, ",[107,1673,1168],{},[107,1675,1022],{}," dominate the score by a wide margin, leaving little signal in the remaining features (price, freight, installments) for any model to exploit. The customer's rating depends on a lot of things that aren't in this dataframe (actual product quality, packaging, support), it's not a lack of a better model, it's a lack of feature.",[31,1678,1680],{"id":1679},"scenario-3-freight-value-regression","Scenario 3: freight value (regression)",[11,1682,1683,1684,1686],{},"Baseline → Linear Regression → Random Forest → XGBoost, on the product's physical features plus Chapter 3's ",[107,1685,1144],{}," (98,650 orders):",[232,1688,1689,1699],{},[235,1690,1691],{},[238,1692,1693,1695,1697],{},[241,1694,1484],{"align":243},[241,1696,1614],{"align":250},[241,1698,1617],{"align":250},[256,1700,1701,1710,1720,1730],{},[238,1702,1703,1705,1707],{},[261,1704,1502],{"align":243},[261,1706,1626],{"align":250},[261,1708,1709],{"align":250},"8.6057",[238,1711,1712,1714,1717],{},[261,1713,1634],{"align":243},[261,1715,1716],{"align":250},"0.5322",[261,1718,1719],{"align":250},"5.3800",[238,1721,1722,1724,1727],{},[261,1723,1534],{"align":243},[261,1725,1726],{"align":250},"0.6178",[261,1728,1729],{"align":250},"4.5947",[238,1731,1732,1734,1737],{},[261,1733,1548],{"align":243},[261,1735,1736],{"align":250},"0.6385",[261,1738,1739],{"align":250},"4.3112",[1562,1741],{"color":1742,"scenario":1743,"x-label":1191,"y-label":1614},"#33aa55","freight",[11,1745,1746],{},"This is where XGBoost clearly wins over the other three, R² 0.6385 and an average error of R$ 4.31. Makes physical sense: carriers compute freight with a formula that blends weight, dimensions and distance in a non-linear way (volumetric weight, distance price brackets), and tree-based models (Random Forest, XGBoost) capture that kind of non-linearity far better than a Linear Regression's straight line, which still comes in a respectable second (R² 0.53). Compared to Scenario 2's low ceiling, this is the scenario where \"more relevant numeric data\" genuinely buys real model performance.",[31,1748,1750],{"id":1749},"scenario-4-customer-segmentation-first-pass-at-rfm","Scenario 4: customer segmentation, first pass at RFM",[11,1752,1753,1754,1758,1759,1762],{},"Unlike the previous three, there's no \"correct model\" to compare here, it's ",[23,1755,1757],{"href":1756},"\u002Fplaylists\u002Fpattern-recognition\u002Fkmeans","K-means"," running across several values of K (from 2 to 8) on Chapter 3's three scaled RFM variables (recency, frequency, monetary value). To pick K, I used a different metric than the elbow method the professor used back in the Pattern Recognition playlist: the ",[15,1760,1761],{},"silhouette coefficient",". It measures, for each customer, how similar they are to their own cluster compared to the nearest neighboring cluster, and the average across everyone becomes that K's score. It ranges from -1 (customer in the wrong cluster) to 1 (tight, well-separated clusters).",[232,1764,1765,1778],{},[235,1766,1767],{},[238,1768,1769,1772,1775],{},[241,1770,1771],{"align":243},"K",[241,1773,1774],{"align":250},"Silhouette",[241,1776,1777],{"align":250},"Inertia",[256,1779,1780,1790,1801,1812,1823,1834,1845],{},[238,1781,1782,1784,1787],{},[261,1783,347],{"align":243},[261,1785,1786],{"align":250},"0.7430",[261,1788,1789],{"align":250},"207,275.6",[238,1791,1792,1795,1798],{},[261,1793,1794],{"align":243},"3",[261,1796,1797],{"align":250},"0.4569",[261,1799,1800],{"align":250},"141,952.7",[238,1802,1803,1806,1809],{},[261,1804,1805],{"align":243},"4",[261,1807,1808],{"align":250},"0.4900",[261,1810,1811],{"align":250},"94,963.3",[238,1813,1814,1817,1820],{},[261,1815,1816],{"align":243},"5",[261,1818,1819],{"align":250},"0.4195",[261,1821,1822],{"align":250},"79,940.5",[238,1824,1825,1828,1831],{},[261,1826,1827],{"align":243},"6",[261,1829,1830],{"align":250},"0.4387",[261,1832,1833],{"align":250},"65,668.3",[238,1835,1836,1839,1842],{},[261,1837,1838],{"align":243},"7",[261,1840,1841],{"align":250},"0.4418",[261,1843,1844],{"align":250},"56,032.0",[238,1846,1847,1850,1853],{},[261,1848,1849],{"align":243},"8",[261,1851,1852],{"align":250},"0.4495",[261,1854,1855],{"align":250},"50,353.9",[1562,1857],{"color":1858,"scenario":1859,"x-label":1860,"y-label":1861},"#8855aa","rfm","k","silhouette",[11,1863,1864],{},"K=2 leads by a wide margin (0.743, nearly double any other K), and inertia drops smoothly and continuously with no obvious elbow, two signals agreeing: the statistically \"cleanest\" cut is splitting the base into just two groups. Except I already know, from Chapter 3, what that cut probably IS: the 3.06% of customers who bought more than once against the 96.94% one-time buyers. Statistically it's the \"purest\" K, but from a business angle it's almost the same information I already had without running any clustering at all. From K=3 onward the silhouette bounces around without a clear pattern (between 0.42 and 0.49), with no clear second-best K standing out. I'll carry that tension (statistically optimal K vs. a segmentation rich enough to act on) into Chapter 5, which is where I actually pick a final K, visualize the clusters, and interpret what each one means.",[31,1866,528],{"id":527},[11,1868,1869],{},"All four models trained and compared against a baseline, with MLflow logging every attempt. The most important finding wasn't \"which model won\", it was the demonstration that a good AUC doesn't guarantee a good decision: Scenario 1's Random Forest has the best ranking metric and zero practical use at the default threshold, a real trap for anyone who checks one metric and calls it a day. Freight is the scenario where the model \"buys\" the most real performance (XGBoost, R² 0.64), review score runs into a low ceiling of available information, and RFM raises an open question about what \"better\" clustering even means, which carries into Chapter 5, along with SHAP to open up these models' black box and the project's final conclusions.",{"title":78,"searchDepth":79,"depth":79,"links":1871},[1872,1873,1874,1875,1876],{"id":1471,"depth":79,"text":1472},{"id":1599,"depth":79,"text":1600},{"id":1679,"depth":79,"text":1680},{"id":1749,"depth":79,"text":1750},{"id":527,"depth":79,"text":528},"Fourth chapter of the Olist case study: training the four real models (delay, review, freight, RFM) with MLflow tracking, and finding a Random Forest with 0.74 AUC that doesn't catch a single positive case at the default threshold.",{},"\u002Fen\u002Fprojects\u002Folist-ecommerce\u002F04-treinamento-e-tracking",{"title":1449,"description":1877},"en\u002Fprojects\u002Folist-ecommerce\u002F04-treinamento-e-tracking",[93,94,1883,1884],"mlflow","model-evaluation","1-ka3U3DWB0AvkmXkNHk37Ap-4cG1COQRY4aQhkyAfM",1787605214728]