saraNour commited on
Commit
f377357
·
verified ·
1 Parent(s): eb3c596

Upload src/phase1.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/phase1.py +424 -0
src/phase1.py ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Phase 1 — Load / Validate / Provenance
4
+ Compliments Reference DB Pipeline
5
+
6
+ Authoritative Input:
7
+ https://huggingface.co/datasets/saraNour/compliments-brand/blob/main/source_of_truth/products.parquet
8
+
9
+ This phase:
10
+ 1. Downloads the authoritative products.parquet from HuggingFace
11
+ 2. Validates schema, row count, nulls, duplicates
12
+ 3. Analyzes UPC patterns, brand values, size fields
13
+ 4. Documents provenance of every column
14
+ 5. Drops 100% null columns with explicit documentation
15
+ 6. Produces clean Phase 1 output + validation + statistics
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ import pandas as pd
25
+ from huggingface_hub import hf_hub_download
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Configuration
29
+ # ---------------------------------------------------------------------------
30
+ HF_REPO = "saraNour/compliments-brand"
31
+ HF_FILE = "source_of_truth/products.parquet"
32
+ HF_REPO_TYPE = "dataset"
33
+
34
+ OUTPUT_DIR = Path(__file__).resolve().parent.parent / "outputs"
35
+ VALIDATION_DIR = Path(__file__).resolve().parent.parent / "validation"
36
+ STATISTICS_DIR = Path(__file__).resolve().parent.parent / "statistics"
37
+
38
+ VERSION = "1.0.0"
39
+ TIMESTAMP = datetime.now(timezone.utc).isoformat()
40
+
41
+
42
+ def log(msg: str) -> None:
43
+ print(f"[Phase1] {msg}")
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # 1. Load
48
+ # ---------------------------------------------------------------------------
49
+ def load_dataset() -> pd.DataFrame:
50
+ log(f"Downloading {HF_REPO}/{HF_FILE} ...")
51
+ path = hf_hub_download(HF_REPO, HF_FILE, repo_type=HF_REPO_TYPE)
52
+ log(f"Downloaded to: {path}")
53
+ df = pd.read_parquet(path)
54
+ log(f"Loaded shape: {df.shape}")
55
+ return df
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # 2. Validate schema
60
+ # ---------------------------------------------------------------------------
61
+ EXPECTED_COLUMNS = [
62
+ "upc", "external_id", "brand", "title", "price", "price_currency",
63
+ "size", "size_amount", "size_unit", "size_qty", "size_per_unit",
64
+ "size_unit_norm", "size_total", "image_url", "source", "source_url",
65
+ ]
66
+
67
+ EXPECTED_DTYPES = {
68
+ "upc": "str",
69
+ "external_id": "str",
70
+ "brand": "str",
71
+ "title": "str",
72
+ "price": "float64",
73
+ "price_currency": "str",
74
+ "size": "str",
75
+ "size_amount": "float64",
76
+ "size_unit": "str",
77
+ "size_qty": "int64",
78
+ "size_per_unit": "object",
79
+ "size_unit_norm": "str",
80
+ "size_total": "object",
81
+ "image_url": "str",
82
+ "source": "str",
83
+ "source_url": "str",
84
+ }
85
+
86
+ EXPECTED_ROW_COUNT = 4440
87
+
88
+
89
+ def validate_schema(df: pd.DataFrame) -> dict:
90
+ checks = {}
91
+
92
+ # Row count
93
+ checks["row_count"] = {
94
+ "expected": EXPECTED_ROW_COUNT,
95
+ "actual": len(df),
96
+ "pass": len(df) == EXPECTED_ROW_COUNT,
97
+ }
98
+
99
+ # Column presence
100
+ missing = [c for c in EXPECTED_COLUMNS if c not in df.columns]
101
+ extra = [c for c in df.columns if c not in EXPECTED_COLUMNS]
102
+ checks["columns"] = {
103
+ "expected_count": len(EXPECTED_COLUMNS),
104
+ "actual_count": len(df.columns),
105
+ "missing": missing,
106
+ "extra": extra,
107
+ "pass": len(missing) == 0,
108
+ }
109
+
110
+ # Column names match exactly
111
+ checks["column_order"] = {
112
+ "expected": EXPECTED_COLUMNS,
113
+ "actual": list(df.columns),
114
+ "pass": list(df.columns) == EXPECTED_COLUMNS,
115
+ }
116
+
117
+ return checks
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # 3. Inspect nulls
122
+ # ---------------------------------------------------------------------------
123
+ def inspect_nulls(df: pd.DataFrame) -> dict:
124
+ null_info = {}
125
+ for col in df.columns:
126
+ n = int(df[col].isna().sum())
127
+ null_info[col] = {
128
+ "null_count": n,
129
+ "null_pct": round(n / len(df) * 100, 2),
130
+ "is_100pct_null": n == len(df),
131
+ }
132
+ return null_info
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # 4. Inspect duplicates
137
+ # ---------------------------------------------------------------------------
138
+ def inspect_duplicates(df: pd.DataFrame) -> dict:
139
+ full_dupes = int(df.duplicated().sum())
140
+ upc_dupes = int(df["upc"].duplicated().sum()) if "upc" in df.columns else 0
141
+ ext_dupes = int(df["external_id"].duplicated().sum()) if "external_id" in df.columns else 0
142
+
143
+ return {
144
+ "full_row_duplicates": full_dupes,
145
+ "upc_duplicates": upc_dupes,
146
+ "external_id_duplicates": ext_dupes,
147
+ "pass": full_dupes == 0 and ext_dupes == 0,
148
+ }
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # 5. UPC analysis
153
+ # ---------------------------------------------------------------------------
154
+ def analyze_upc(df: pd.DataFrame) -> dict:
155
+ upc = df["upc"]
156
+ total = len(upc)
157
+ nulls = int(upc.isna().sum())
158
+ unique = int(upc.nunique())
159
+
160
+ # Reused UPCs (appear more than once)
161
+ counts = upc.value_counts()
162
+ reused = counts[counts > 1]
163
+ reused_upcs = reused.to_dict()
164
+
165
+ return {
166
+ "total_rows": total,
167
+ "null_count": nulls,
168
+ "unique_count": unique,
169
+ "reused_upc_count": int(len(reused)),
170
+ "reused_upc_examples": {str(k): int(v) for k, v in list(reused_upcs.items())[:10]},
171
+ "null_upc_rows": df[upc.isna()][["external_id", "title", "brand"]].to_dict("records"),
172
+ }
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # 6. Brand analysis
177
+ # ---------------------------------------------------------------------------
178
+ def analyze_brand(df: pd.DataFrame) -> dict:
179
+ brand_counts = df["brand"].value_counts()
180
+ return {
181
+ "unique_count": int(brand_counts.shape[0]),
182
+ "distribution": {str(k): int(v) for k, v in brand_counts.items()},
183
+ }
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # 7. Size analysis
188
+ # ---------------------------------------------------------------------------
189
+ def analyze_size(df: pd.DataFrame) -> dict:
190
+ size_col = df["size"]
191
+ size_amount = df["size_amount"]
192
+ size_unit = df["size_unit"]
193
+
194
+ return {
195
+ "size_string": {
196
+ "unique_count": int(size_col.nunique()),
197
+ "null_count": int(size_col.isna().sum()),
198
+ "top_20": {str(k): int(v) for k, v in size_col.value_counts().head(20).items()},
199
+ },
200
+ "size_amount": {
201
+ "null_count": int(size_amount.isna().sum()),
202
+ "null_pct": round(size_amount.isna().sum() / len(df) * 100, 2),
203
+ "min": float(size_amount.min()) if size_amount.notna().any() else None,
204
+ "max": float(size_amount.max()) if size_amount.notna().any() else None,
205
+ "mean": round(float(size_amount.mean()), 2) if size_amount.notna().any() else None,
206
+ "median": round(float(size_amount.median()), 2) if size_amount.notna().any() else None,
207
+ },
208
+ "size_unit": {
209
+ "null_count": int(size_unit.isna().sum()),
210
+ "null_pct": round(size_unit.isna().sum() / len(df) * 100, 2),
211
+ "distribution": {str(k): int(v) for k, v in size_unit.value_counts().items()},
212
+ },
213
+ }
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # 8. Provenance documentation
218
+ # ---------------------------------------------------------------------------
219
+ def document_provenance() -> dict:
220
+ return {
221
+ "source_dataset": f"{HF_REPO}/{HF_FILE}",
222
+ "source_url": f"https://huggingface.co/datasets/{HF_REPO}/blob/main/{HF_FILE}",
223
+ "source_type": "HuggingFace dataset (private)",
224
+ "access_method": "huggingface_hub.hf_hub_download",
225
+ "original_source": "Voila.ca (Loblaw) Compliments private-label products",
226
+ "columns": {
227
+ "upc": "Universal Product Code. 1 null. 3,271 unique. Some reused across variants.",
228
+ "external_id": "Voila retailer product ID. 4,440 unique. No nulls.",
229
+ "brand": "Product brand. 11 variants of Compliments/Sensations.",
230
+ "title": "Raw product title from Voila. 4,375 unique.",
231
+ "price": "Price in CAD. 247 unique values.",
232
+ "price_currency": "Always 'CAD'.",
233
+ "size": "Raw size string from Voila. 631 unique values.",
234
+ "size_amount": "Parsed numeric size amount. 307 nulls (6.9%).",
235
+ "size_unit": "Parsed size unit. 307 nulls (6.9%).",
236
+ "size_qty": "Size quantity multiplier. Usually 1.",
237
+ "size_per_unit": "100% null. All values are NaN. DROPPED.",
238
+ "size_unit_norm": "Normalized size unit. 307 nulls (6.9%).",
239
+ "size_total": "100% null. All values are NaN. DROPPED.",
240
+ "image_url": "Product image URL from Voila. 4,440 unique.",
241
+ "source": "Always 'voila'.",
242
+ "source_url": "Product page URL on Voila. 4,440 unique.",
243
+ },
244
+ "dropped_columns": [
245
+ {
246
+ "column": "size_per_unit",
247
+ "reason": "100% null (4,440/4,440 values are NaN). No usable data.",
248
+ },
249
+ {
250
+ "column": "size_total",
251
+ "reason": "100% null (4,440/4,440 values are NaN). No usable data.",
252
+ },
253
+ ],
254
+ "preserved_columns": [
255
+ "upc", "external_id", "brand", "title", "price", "price_currency",
256
+ "size", "size_amount", "size_unit", "size_qty", "size_unit_norm",
257
+ "image_url", "source", "source_url",
258
+ ],
259
+ }
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # 9. Build statistics
264
+ # ---------------------------------------------------------------------------
265
+ def build_statistics(df: pd.DataFrame, null_info: dict, dup_info: dict,
266
+ upc_info: dict, brand_info: dict, size_info: dict) -> dict:
267
+ cols_100pct_null = [c for c, v in null_info.items() if v["is_100pct_null"]]
268
+ return {
269
+ "version": VERSION,
270
+ "timestamp": TIMESTAMP,
271
+ "input": {
272
+ "source": f"{HF_REPO}/{HF_FILE}",
273
+ "row_count": len(df),
274
+ "column_count": len(df.columns),
275
+ },
276
+ "output": {
277
+ "row_count": len(df),
278
+ "column_count": len(df.columns) - len(cols_100pct_null),
279
+ "columns_dropped": cols_100pct_null,
280
+ },
281
+ "nulls": {
282
+ "columns_with_nulls": {c: v for c, v in null_info.items() if v["null_count"] > 0},
283
+ "columns_100pct_null": cols_100pct_null,
284
+ },
285
+ "duplicates": dup_info,
286
+ "upc": upc_info,
287
+ "brand": brand_info,
288
+ "size": size_info,
289
+ }
290
+
291
+
292
+ # ---------------------------------------------------------------------------
293
+ # 10. Build validation report
294
+ # ---------------------------------------------------------------------------
295
+ def build_validation(schema_checks: dict, null_info: dict, dup_info: dict) -> dict:
296
+ all_pass = True
297
+ failures = []
298
+
299
+ # Schema checks
300
+ for key, check in schema_checks.items():
301
+ if not check.get("pass", True):
302
+ all_pass = False
303
+ failures.append(f"schema.{key}")
304
+
305
+ # Null checks
306
+ non_trivial_nulls = {
307
+ c: v for c, v in null_info.items()
308
+ if v["null_count"] > 0 and not v["is_100pct_null"]
309
+ }
310
+ # 100% null columns are expected (size_per_unit, size_total)
311
+ expected_100pct = {"size_per_unit", "size_total"}
312
+ unexpected_100pct = [c for c, v in null_info.items()
313
+ if v["is_100pct_null"] and c not in expected_100pct]
314
+ if unexpected_100pct:
315
+ all_pass = False
316
+ failures.append(f"unexpected_100pct_null_columns: {unexpected_100pct}")
317
+
318
+ # Duplicate checks
319
+ if not dup_info["pass"]:
320
+ all_pass = False
321
+ failures.append("duplicates")
322
+
323
+ return {
324
+ "version": VERSION,
325
+ "timestamp": TIMESTAMP,
326
+ "result": "PASS" if all_pass else "FAIL",
327
+ "failures": failures,
328
+ "checks": {
329
+ "schema": schema_checks,
330
+ "duplicates": dup_info,
331
+ "non_trivial_nulls": non_trivial_nulls,
332
+ "unexpected_100pct_null_columns": unexpected_100pct,
333
+ },
334
+ }
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Main
339
+ # ---------------------------------------------------------------------------
340
+ def main():
341
+ log("Starting Phase 1")
342
+
343
+ # Ensure output dirs exist
344
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
345
+ VALIDATION_DIR.mkdir(parents=True, exist_ok=True)
346
+ STATISTICS_DIR.mkdir(parents=True, exist_ok=True)
347
+
348
+ # 1. Load
349
+ df = load_dataset()
350
+
351
+ # 2. Validate schema
352
+ log("Validating schema ...")
353
+ schema_checks = validate_schema(df)
354
+
355
+ # 3. Nulls
356
+ log("Inspecting nulls ...")
357
+ null_info = inspect_nulls(df)
358
+
359
+ # 4. Duplicates
360
+ log("Inspecting duplicates ...")
361
+ dup_info = inspect_duplicates(df)
362
+
363
+ # 5. UPC analysis
364
+ log("Analyzing UPCs ...")
365
+ upc_info = analyze_upc(df)
366
+
367
+ # 6. Brand analysis
368
+ log("Analyzing brands ...")
369
+ brand_info = analyze_brand(df)
370
+
371
+ # 7. Size analysis
372
+ log("Analyzing sizes ...")
373
+ size_info = analyze_size(df)
374
+
375
+ # 8. Provenance
376
+ log("Documenting provenance ...")
377
+ provenance = document_provenance()
378
+
379
+ # 9. Statistics
380
+ log("Building statistics ...")
381
+ statistics = build_statistics(df, null_info, dup_info, upc_info, brand_info, size_info)
382
+
383
+ # 10. Validation
384
+ log("Building validation report ...")
385
+ validation = build_validation(schema_checks, null_info, dup_info)
386
+
387
+ # 11. Drop 100% null columns
388
+ cols_to_drop = [c for c, v in null_info.items() if v["is_100pct_null"]]
389
+ log(f"Dropping 100% null columns: {cols_to_drop}")
390
+ df_out = df.drop(columns=cols_to_drop)
391
+
392
+ # 12. Save outputs
393
+ log("Saving outputs ...")
394
+ df_out.to_parquet(OUTPUT_DIR / "phase1_output.parquet", index=False)
395
+ log(f" Saved phase1_output.parquet ({df_out.shape[0]} rows, {df_out.shape[1]} cols)")
396
+
397
+ with open(VALIDATION_DIR / "phase1_validation.json", "w") as f:
398
+ json.dump(validation, f, indent=2, default=str)
399
+ log(" Saved phase1_validation.json")
400
+
401
+ with open(STATISTICS_DIR / "phase1_statistics.json", "w") as f:
402
+ json.dump(statistics, f, indent=2, default=str)
403
+ log(" Saved phase1_statistics.json")
404
+
405
+ with open(OUTPUT_DIR / "phase1_provenance.json", "w") as f:
406
+ json.dump(provenance, f, indent=2, default=str)
407
+ log(" Saved phase1_provenance.json")
408
+
409
+ # Summary
410
+ log("")
411
+ log("=== PHASE 1 COMPLETE ===")
412
+ log(f"Input: {df.shape[0]} rows, {df.shape[1]} columns")
413
+ log(f"Output: {df_out.shape[0]} rows, {df_out.shape[1]} columns")
414
+ log(f"Dropped columns: {cols_to_drop}")
415
+ log(f"Validation: {validation['result']}")
416
+ if validation["failures"]:
417
+ log(f"Failures: {validation['failures']}")
418
+ log("========================")
419
+
420
+ return df_out, validation, statistics
421
+
422
+
423
+ if __name__ == "__main__":
424
+ main()