tigerdatalab 3.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tigerdatalab/__init__.py +17 -0
- tigerdatalab/analytics/__init__.py +0 -0
- tigerdatalab/analytics/category.py +38 -0
- tigerdatalab/analytics/customer.py +51 -0
- tigerdatalab/analytics/growth.py +86 -0
- tigerdatalab/analytics/kpi.py +66 -0
- tigerdatalab/analytics/product.py +40 -0
- tigerdatalab/analytics/profitability.py +24 -0
- tigerdatalab/analytics/trends.py +79 -0
- tigerdatalab/cli/__init__.py +0 -0
- tigerdatalab/cli/main.py +77 -0
- tigerdatalab/config.py +47 -0
- tigerdatalab/core.py +319 -0
- tigerdatalab/dashboard/__init__.py +0 -0
- tigerdatalab/dashboard/builder.py +145 -0
- tigerdatalab/dataops/__init__.py +0 -0
- tigerdatalab/dataops/asset.py +144 -0
- tigerdatalab/exceptions.py +71 -0
- tigerdatalab/insights/__init__.py +0 -0
- tigerdatalab/insights/engine.py +175 -0
- tigerdatalab/insights/questions.py +112 -0
- tigerdatalab/io/__init__.py +0 -0
- tigerdatalab/io/loaders.py +237 -0
- tigerdatalab/quality/__init__.py +0 -0
- tigerdatalab/quality/anomalies.py +45 -0
- tigerdatalab/quality/cleaning.py +85 -0
- tigerdatalab/quality/profiler.py +146 -0
- tigerdatalab/quality/types.py +163 -0
- tigerdatalab/reporting/__init__.py +0 -0
- tigerdatalab/reporting/_safe_io.py +63 -0
- tigerdatalab/reporting/exporters.py +34 -0
- tigerdatalab/reporting/html.py +44 -0
- tigerdatalab/reporting/pdf.py +168 -0
- tigerdatalab/reporting/terminal.py +137 -0
- tigerdatalab/scale/__init__.py +0 -0
- tigerdatalab/scale/duckdb_engine.py +96 -0
- tigerdatalab/visualization/__init__.py +0 -0
- tigerdatalab/visualization/charts.py +247 -0
- tigerdatalab-3.0.4.dist-info/METADATA +235 -0
- tigerdatalab-3.0.4.dist-info/RECORD +44 -0
- tigerdatalab-3.0.4.dist-info/WHEEL +5 -0
- tigerdatalab-3.0.4.dist-info/entry_points.txt +2 -0
- tigerdatalab-3.0.4.dist-info/licenses/LICENSE +21 -0
- tigerdatalab-3.0.4.dist-info/top_level.txt +1 -0
tigerdatalab/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""TigerDataLab — an automated Data Analytics + Data Quality + Visualization
|
|
2
|
+
+ BI + DataOps layer on top of pandas, numpy, duckdb and plotly.
|
|
3
|
+
|
|
4
|
+
import tigerdatalab as tdl
|
|
5
|
+
result = tdl.analyze("sales.xlsx")
|
|
6
|
+
print(result.summary())
|
|
7
|
+
result.report("analysis")
|
|
8
|
+
"""
|
|
9
|
+
from .config import __version__
|
|
10
|
+
from .core import AnalysisResult, analyze, open, large, profile, quality_check, clean_file
|
|
11
|
+
from .exceptions import TigerDataLabError, UnsupportedFileTypeError, NoTrendDataError, NoCustomerIdentifierError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"__version__", "AnalysisResult", "analyze", "open", "large",
|
|
15
|
+
"profile", "quality_check", "clean_file",
|
|
16
|
+
"TigerDataLabError", "UnsupportedFileTypeError", "NoTrendDataError", "NoCustomerIdentifierError",
|
|
17
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Category-level analytics + Pareto data for chart engine."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def analyze_categories(df: pd.DataFrame, semantics: dict) -> dict:
|
|
8
|
+
category_col = semantics.get("category")
|
|
9
|
+
if not category_col or category_col not in df.columns:
|
|
10
|
+
return {"available": False, "reason": "No category/segment column was detected."}
|
|
11
|
+
|
|
12
|
+
revenue_col = semantics.get("revenue")
|
|
13
|
+
profit_col = semantics.get("profit")
|
|
14
|
+
|
|
15
|
+
grouped = df.groupby(category_col)
|
|
16
|
+
result: dict = {"available": True, "category_column": category_col, "unique_categories": int(df[category_col].nunique())}
|
|
17
|
+
|
|
18
|
+
if revenue_col and revenue_col in df.columns:
|
|
19
|
+
rev = grouped[revenue_col].apply(lambda s: pd.to_numeric(s, errors="coerce").sum()).sort_values(ascending=False)
|
|
20
|
+
total = rev.sum()
|
|
21
|
+
result["revenue_by_category"] = [{"category": str(k), "revenue": float(v),
|
|
22
|
+
"share_pct": round(float(100 * v / total), 2) if total else 0}
|
|
23
|
+
for k, v in rev.items()]
|
|
24
|
+
if total:
|
|
25
|
+
result["top_category_revenue_share_pct"] = round(float(100 * rev.iloc[0] / total), 2)
|
|
26
|
+
result["top_category"] = str(rev.index[0])
|
|
27
|
+
|
|
28
|
+
if profit_col and profit_col in df.columns:
|
|
29
|
+
profit = grouped[profit_col].apply(lambda s: pd.to_numeric(s, errors="coerce").sum()).sort_values(ascending=False)
|
|
30
|
+
result["profit_by_category"] = [{"category": str(k), "profit": float(v)} for k, v in profit.items()]
|
|
31
|
+
if revenue_col and revenue_col in df.columns:
|
|
32
|
+
rev_by_cat = grouped[revenue_col].apply(lambda s: pd.to_numeric(s, errors="coerce").sum())
|
|
33
|
+
margin = (profit / rev_by_cat.reindex(profit.index) * 100).round(2)
|
|
34
|
+
margin_sorted = margin.sort_values()
|
|
35
|
+
result["worst_margin_category"] = str(margin_sorted.index[0]) if len(margin_sorted) else None
|
|
36
|
+
result["margin_by_category"] = [{"category": str(k), "margin_pct": float(v)} for k, v in margin.items() if pd.notna(v)]
|
|
37
|
+
|
|
38
|
+
return result
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Customer-level analytics. Raises NoCustomerIdentifierError when no
|
|
2
|
+
customer identifier column was detected, rather than inventing numbers."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from ..exceptions import NoCustomerIdentifierError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def analyze_customers(df: pd.DataFrame, semantics: dict) -> dict:
|
|
11
|
+
customer_col = semantics.get("customer")
|
|
12
|
+
if not customer_col or customer_col not in df.columns:
|
|
13
|
+
raise NoCustomerIdentifierError()
|
|
14
|
+
|
|
15
|
+
revenue_col = semantics.get("revenue")
|
|
16
|
+
profit_col = semantics.get("profit")
|
|
17
|
+
order_col = semantics.get("order")
|
|
18
|
+
|
|
19
|
+
grouped = df.groupby(customer_col)
|
|
20
|
+
n_customers = int(df[customer_col].nunique())
|
|
21
|
+
|
|
22
|
+
result: dict = {"customer_column": customer_col, "unique_customers": n_customers}
|
|
23
|
+
|
|
24
|
+
if order_col and order_col in df.columns:
|
|
25
|
+
orders_per_customer = grouped[order_col].nunique()
|
|
26
|
+
result["avg_orders_per_customer"] = round(float(orders_per_customer.mean()), 2)
|
|
27
|
+
result["repeat_customers"] = int((orders_per_customer > 1).sum())
|
|
28
|
+
result["one_time_customers"] = int((orders_per_customer == 1).sum())
|
|
29
|
+
else:
|
|
30
|
+
orders_per_customer = grouped.size()
|
|
31
|
+
result["avg_orders_per_customer"] = round(float(orders_per_customer.mean()), 2)
|
|
32
|
+
result["repeat_customers"] = int((orders_per_customer > 1).sum())
|
|
33
|
+
result["one_time_customers"] = int((orders_per_customer == 1).sum())
|
|
34
|
+
|
|
35
|
+
if revenue_col and revenue_col in df.columns:
|
|
36
|
+
rev_by_customer = grouped[revenue_col].apply(lambda s: pd.to_numeric(s, errors="coerce").sum())
|
|
37
|
+
total_rev = rev_by_customer.sum()
|
|
38
|
+
result["avg_revenue_per_customer"] = round(float(rev_by_customer.mean()), 2)
|
|
39
|
+
top = rev_by_customer.sort_values(ascending=False).head(10)
|
|
40
|
+
result["top_customers"] = [{"customer": str(k), "revenue": float(v)} for k, v in top.items()]
|
|
41
|
+
bottom = rev_by_customer.sort_values(ascending=True).head(10)
|
|
42
|
+
result["bottom_customers"] = [{"customer": str(k), "revenue": float(v)} for k, v in bottom.items()]
|
|
43
|
+
if total_rev:
|
|
44
|
+
top5_share = top.head(5).sum() / total_rev * 100
|
|
45
|
+
result["top5_revenue_concentration_pct"] = round(float(top5_share), 2)
|
|
46
|
+
|
|
47
|
+
if profit_col and profit_col in df.columns:
|
|
48
|
+
profit_by_customer = grouped[profit_col].apply(lambda s: pd.to_numeric(s, errors="coerce").sum())
|
|
49
|
+
result["avg_profit_per_customer"] = round(float(profit_by_customer.mean()), 2)
|
|
50
|
+
|
|
51
|
+
return result
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Growth/decline detection per product and per category.
|
|
2
|
+
|
|
3
|
+
Splits the dataset's date range into an earlier half and a later half,
|
|
4
|
+
compares the chosen metric (revenue by default) per group, and tags each
|
|
5
|
+
group as growing / declining / flat. Requires a date column; degrades to
|
|
6
|
+
`{"available": False, ...}` when one isn't present rather than crashing.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from ..quality.types import parse_date_column
|
|
13
|
+
|
|
14
|
+
FLAT_BAND_PCT = 5.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _growth_by_group(df: pd.DataFrame, group_col: str, date_col: str, metric_col: str) -> dict:
|
|
18
|
+
dates = parse_date_column(df[date_col])
|
|
19
|
+
metric = pd.to_numeric(df[metric_col], errors="coerce")
|
|
20
|
+
work = pd.DataFrame({"group": df[group_col], "date": dates, "value": metric}).dropna()
|
|
21
|
+
if work.empty or work["date"].nunique() < 2:
|
|
22
|
+
return {"available": False, "reason": "Not enough valid date-tagged rows to compute growth."}
|
|
23
|
+
|
|
24
|
+
midpoint = work["date"].min() + (work["date"].max() - work["date"].min()) / 2
|
|
25
|
+
first_half = work[work["date"] <= midpoint].groupby("group")["value"].sum()
|
|
26
|
+
second_half = work[work["date"] > midpoint].groupby("group")["value"].sum()
|
|
27
|
+
|
|
28
|
+
all_groups = set(first_half.index) | set(second_half.index)
|
|
29
|
+
rows = []
|
|
30
|
+
for g in all_groups:
|
|
31
|
+
f = float(first_half.get(g, 0.0))
|
|
32
|
+
s = float(second_half.get(g, 0.0))
|
|
33
|
+
if f == 0 and s == 0:
|
|
34
|
+
continue
|
|
35
|
+
if f == 0:
|
|
36
|
+
change_pct = 100.0 if s > 0 else 0.0
|
|
37
|
+
else:
|
|
38
|
+
change_pct = round(100 * (s - f) / abs(f), 2)
|
|
39
|
+
if change_pct > FLAT_BAND_PCT:
|
|
40
|
+
status = "growing"
|
|
41
|
+
elif change_pct < -FLAT_BAND_PCT:
|
|
42
|
+
status = "declining"
|
|
43
|
+
else:
|
|
44
|
+
status = "flat"
|
|
45
|
+
rows.append({"group": str(g), "first_half": round(f, 2), "second_half": round(s, 2),
|
|
46
|
+
"change_pct": change_pct, "status": status})
|
|
47
|
+
|
|
48
|
+
rows.sort(key=lambda r: r["change_pct"])
|
|
49
|
+
growing = [r for r in rows if r["status"] == "growing"]
|
|
50
|
+
declining = [r for r in rows if r["status"] == "declining"]
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
"available": True,
|
|
54
|
+
"group_column": group_col,
|
|
55
|
+
"metric_column": metric_col,
|
|
56
|
+
"split_date": midpoint.strftime("%Y-%m-%d"),
|
|
57
|
+
"growing": sorted(growing, key=lambda r: -r["change_pct"])[:10],
|
|
58
|
+
"declining": declining[:10],
|
|
59
|
+
"all": rows,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def analyze_growth(df: pd.DataFrame, semantics: dict) -> dict:
|
|
64
|
+
date_col = semantics.get("date")
|
|
65
|
+
metric_col = semantics.get("revenue") or semantics.get("profit") or semantics.get("quantity")
|
|
66
|
+
result = {"product": {"available": False}, "category": {"available": False}}
|
|
67
|
+
|
|
68
|
+
if not date_col or not metric_col:
|
|
69
|
+
result["product"] = {"available": False, "reason": "No date + numeric metric available for growth analysis."}
|
|
70
|
+
result["category"] = result["product"]
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
product_col = semantics.get("product")
|
|
74
|
+
category_col = semantics.get("category")
|
|
75
|
+
|
|
76
|
+
if product_col and product_col in df.columns:
|
|
77
|
+
result["product"] = _growth_by_group(df, product_col, date_col, metric_col)
|
|
78
|
+
else:
|
|
79
|
+
result["product"] = {"available": False, "reason": "No product column detected."}
|
|
80
|
+
|
|
81
|
+
if category_col and category_col in df.columns:
|
|
82
|
+
result["category"] = _growth_by_group(df, category_col, date_col, metric_col)
|
|
83
|
+
else:
|
|
84
|
+
result["category"] = {"available": False, "reason": "No category column detected."}
|
|
85
|
+
|
|
86
|
+
return result
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Business KPI calculation from detected semantic columns."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def calculate_kpis(df: pd.DataFrame, semantics: dict) -> dict:
|
|
8
|
+
kpis: dict = {}
|
|
9
|
+
|
|
10
|
+
def col(concept):
|
|
11
|
+
c = semantics.get(concept)
|
|
12
|
+
return pd.to_numeric(df[c], errors="coerce") if c and c in df.columns else None
|
|
13
|
+
|
|
14
|
+
revenue = col("revenue")
|
|
15
|
+
cost = col("cost")
|
|
16
|
+
profit = col("profit")
|
|
17
|
+
quantity = col("quantity")
|
|
18
|
+
discount = col("discount")
|
|
19
|
+
|
|
20
|
+
if revenue is not None:
|
|
21
|
+
kpis["total_revenue"] = float(revenue.sum())
|
|
22
|
+
kpis["average_selling_price"] = float(revenue.mean())
|
|
23
|
+
|
|
24
|
+
if cost is not None:
|
|
25
|
+
kpis["total_cost"] = float(cost.sum())
|
|
26
|
+
|
|
27
|
+
if profit is None and revenue is not None and cost is not None:
|
|
28
|
+
profit = revenue - cost
|
|
29
|
+
kpis["profit_derived"] = True
|
|
30
|
+
|
|
31
|
+
if profit is not None:
|
|
32
|
+
kpis["total_profit"] = float(profit.sum())
|
|
33
|
+
if revenue is not None and revenue.sum() != 0:
|
|
34
|
+
kpis["profit_margin_pct"] = round(100 * profit.sum() / revenue.sum(), 2)
|
|
35
|
+
|
|
36
|
+
if quantity is not None:
|
|
37
|
+
kpis["total_quantity"] = float(quantity.sum())
|
|
38
|
+
if revenue is not None and quantity.sum() != 0:
|
|
39
|
+
kpis["average_selling_price"] = round(float(revenue.sum() / quantity.sum()), 2)
|
|
40
|
+
|
|
41
|
+
order_col = semantics.get("order")
|
|
42
|
+
if order_col and order_col in df.columns:
|
|
43
|
+
n_orders = int(df[order_col].nunique())
|
|
44
|
+
kpis["orders"] = n_orders
|
|
45
|
+
if revenue is not None and n_orders:
|
|
46
|
+
kpis["average_order_value"] = round(float(revenue.sum() / n_orders), 2)
|
|
47
|
+
else:
|
|
48
|
+
kpis["orders"] = int(len(df))
|
|
49
|
+
if revenue is not None and len(df):
|
|
50
|
+
kpis["average_order_value"] = round(float(revenue.sum() / len(df)), 2)
|
|
51
|
+
|
|
52
|
+
customer_col = semantics.get("customer")
|
|
53
|
+
if customer_col and customer_col in df.columns:
|
|
54
|
+
kpis["customers"] = int(df[customer_col].nunique())
|
|
55
|
+
|
|
56
|
+
product_col = semantics.get("product")
|
|
57
|
+
if product_col and product_col in df.columns:
|
|
58
|
+
kpis["products"] = int(df[product_col].nunique())
|
|
59
|
+
|
|
60
|
+
if discount is not None:
|
|
61
|
+
kpis["average_discount_pct"] = round(float(discount.mean()), 2)
|
|
62
|
+
if revenue is not None and revenue.sum() != 0:
|
|
63
|
+
kpis["total_discount_value"] = round(float((revenue * discount / 100).sum()), 2) \
|
|
64
|
+
if discount.max() <= 1.5 * 100 and discount.max() > 1 else None
|
|
65
|
+
|
|
66
|
+
return kpis
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Product-level analytics."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def analyze_products(df: pd.DataFrame, semantics: dict) -> dict:
|
|
8
|
+
product_col = semantics.get("product")
|
|
9
|
+
if not product_col or product_col not in df.columns:
|
|
10
|
+
return {"available": False, "reason": "No product identifier/name column was detected."}
|
|
11
|
+
|
|
12
|
+
revenue_col = semantics.get("revenue")
|
|
13
|
+
profit_col = semantics.get("profit")
|
|
14
|
+
quantity_col = semantics.get("quantity")
|
|
15
|
+
discount_col = semantics.get("discount")
|
|
16
|
+
|
|
17
|
+
grouped = df.groupby(product_col)
|
|
18
|
+
result: dict = {"available": True, "product_column": product_col, "unique_products": int(df[product_col].nunique())}
|
|
19
|
+
|
|
20
|
+
if revenue_col and revenue_col in df.columns:
|
|
21
|
+
rev = grouped[revenue_col].apply(lambda s: pd.to_numeric(s, errors="coerce").sum()).sort_values(ascending=False)
|
|
22
|
+
result["top_products_by_revenue"] = [{"product": str(k), "revenue": float(v)} for k, v in rev.head(10).items()]
|
|
23
|
+
|
|
24
|
+
if quantity_col and quantity_col in df.columns:
|
|
25
|
+
qty = grouped[quantity_col].apply(lambda s: pd.to_numeric(s, errors="coerce").sum()).sort_values(ascending=False)
|
|
26
|
+
result["top_products_by_quantity"] = [{"product": str(k), "quantity": float(v)} for k, v in qty.head(10).items()]
|
|
27
|
+
|
|
28
|
+
if profit_col and profit_col in df.columns:
|
|
29
|
+
profit = grouped[profit_col].apply(lambda s: pd.to_numeric(s, errors="coerce").sum()).sort_values(ascending=False)
|
|
30
|
+
result["top_products_by_profit"] = [{"product": str(k), "profit": float(v)} for k, v in profit.head(10).items()]
|
|
31
|
+
result["worst_products_by_profit"] = [{"product": str(k), "profit": float(v)} for k, v in profit.tail(10).items()]
|
|
32
|
+
negative = profit[profit < 0]
|
|
33
|
+
result["loss_making_products"] = [{"product": str(k), "profit": float(v)} for k, v in negative.items()]
|
|
34
|
+
result["loss_making_product_count"] = int(len(negative))
|
|
35
|
+
|
|
36
|
+
if discount_col and discount_col in df.columns:
|
|
37
|
+
disc = grouped[discount_col].apply(lambda s: pd.to_numeric(s, errors="coerce").mean()).sort_values(ascending=False)
|
|
38
|
+
result["highest_discount_products"] = [{"product": str(k), "avg_discount": float(v)} for k, v in disc.head(10).items()]
|
|
39
|
+
|
|
40
|
+
return result
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Profitability derivation: profit = revenue - cost when profit is absent."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def ensure_profit_column(df: pd.DataFrame, semantics: dict) -> tuple[pd.DataFrame, dict, bool]:
|
|
8
|
+
"""Return (df, semantics, derived) - adds a `_tdl_profit` column and
|
|
9
|
+
registers it under semantics['profit'] only if profit is not already
|
|
10
|
+
present but revenue and cost both are. Never overwrites an existing
|
|
11
|
+
business column."""
|
|
12
|
+
if semantics.get("profit"):
|
|
13
|
+
return df, semantics, False
|
|
14
|
+
|
|
15
|
+
revenue_col = semantics.get("revenue")
|
|
16
|
+
cost_col = semantics.get("cost")
|
|
17
|
+
if revenue_col and cost_col and revenue_col in df.columns and cost_col in df.columns:
|
|
18
|
+
out = df.copy()
|
|
19
|
+
out["_tdl_profit"] = pd.to_numeric(out[revenue_col], errors="coerce") - pd.to_numeric(out[cost_col], errors="coerce")
|
|
20
|
+
new_semantics = dict(semantics)
|
|
21
|
+
new_semantics["profit"] = "_tdl_profit"
|
|
22
|
+
return out, new_semantics, True
|
|
23
|
+
|
|
24
|
+
return df, semantics, False
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Time-trend analysis: monthly/daily aggregation, MoM, YoY, rolling avg."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
from ..exceptions import NoTrendDataError
|
|
7
|
+
from ..quality.types import parse_date_column, numeric_columns, detect_all_dtypes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _pick_metric(df: pd.DataFrame, semantics: dict) -> str | None:
|
|
11
|
+
for concept in ("revenue", "profit", "cost", "quantity"):
|
|
12
|
+
col = semantics.get(concept)
|
|
13
|
+
if col and col in df.columns:
|
|
14
|
+
return col
|
|
15
|
+
numeric = numeric_columns(df)
|
|
16
|
+
return numeric[0] if numeric else None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compute_trend(df: pd.DataFrame, semantics: dict) -> dict:
|
|
20
|
+
date_col = semantics.get("date")
|
|
21
|
+
if not date_col or date_col not in df.columns:
|
|
22
|
+
raise NoTrendDataError(candidates=[])
|
|
23
|
+
|
|
24
|
+
metric_col = _pick_metric(df, semantics)
|
|
25
|
+
if not metric_col:
|
|
26
|
+
raise NoTrendDataError(candidates=[date_col])
|
|
27
|
+
|
|
28
|
+
dates = parse_date_column(df[date_col])
|
|
29
|
+
metric = pd.to_numeric(df[metric_col], errors="coerce")
|
|
30
|
+
work = pd.DataFrame({"date": dates, "value": metric}).dropna()
|
|
31
|
+
|
|
32
|
+
if work.empty or work["date"].nunique() < 2:
|
|
33
|
+
raise NoTrendDataError(candidates=[date_col])
|
|
34
|
+
|
|
35
|
+
span_days = (work["date"].max() - work["date"].min()).days
|
|
36
|
+
freq = "D" if span_days <= 62 else "M"
|
|
37
|
+
|
|
38
|
+
work = work.set_index("date").sort_index()
|
|
39
|
+
if freq == "D":
|
|
40
|
+
agg = work["value"].resample("D").sum()
|
|
41
|
+
granularity = "daily"
|
|
42
|
+
else:
|
|
43
|
+
agg = work["value"].resample("ME").sum()
|
|
44
|
+
granularity = "monthly"
|
|
45
|
+
|
|
46
|
+
agg = agg[agg.index.notna()]
|
|
47
|
+
result_df = agg.reset_index()
|
|
48
|
+
result_df.columns = ["period", "value"]
|
|
49
|
+
|
|
50
|
+
growth_pct = None
|
|
51
|
+
if len(agg) >= 2 and agg.iloc[0] != 0:
|
|
52
|
+
growth_pct = round(100 * (agg.iloc[-1] - agg.iloc[0]) / abs(agg.iloc[0]), 2)
|
|
53
|
+
|
|
54
|
+
mom_pct = None
|
|
55
|
+
if len(agg) >= 2 and agg.iloc[-2] != 0:
|
|
56
|
+
mom_pct = round(100 * (agg.iloc[-1] - agg.iloc[-2]) / abs(agg.iloc[-2]), 2)
|
|
57
|
+
|
|
58
|
+
yoy_pct = None
|
|
59
|
+
if granularity == "monthly" and len(agg) >= 13 and agg.iloc[-13] != 0:
|
|
60
|
+
yoy_pct = round(100 * (agg.iloc[-1] - agg.iloc[-13]) / abs(agg.iloc[-13]), 2)
|
|
61
|
+
|
|
62
|
+
rolling = agg.rolling(window=3, min_periods=1).mean()
|
|
63
|
+
|
|
64
|
+
label = {"revenue": "Revenue", "profit": "Profit", "cost": "Cost", "quantity": "Quantity"}
|
|
65
|
+
metric_label = next((v for k, v in label.items() if semantics.get(k) == metric_col), metric_col)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"date_column": date_col,
|
|
69
|
+
"metric_column": metric_col,
|
|
70
|
+
"metric_label": metric_label,
|
|
71
|
+
"granularity": granularity,
|
|
72
|
+
"title": f"{granularity.capitalize()} {metric_label} Trend",
|
|
73
|
+
"periods": [p.strftime("%Y-%m-%d") for p in result_df["period"]],
|
|
74
|
+
"values": [float(v) for v in result_df["value"]],
|
|
75
|
+
"rolling_average": [float(v) for v in rolling.values],
|
|
76
|
+
"growth_pct": growth_pct,
|
|
77
|
+
"mom_pct": mom_pct,
|
|
78
|
+
"yoy_pct": yoy_pct,
|
|
79
|
+
}
|
|
File without changes
|
tigerdatalab/cli/main.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""TigerDataLab command-line interface.
|
|
2
|
+
|
|
3
|
+
tigerdatalab analyze sales.csv
|
|
4
|
+
tigerdatalab dashboard sales.csv
|
|
5
|
+
tigerdatalab profile sales.csv
|
|
6
|
+
tigerdatalab quality sales.csv
|
|
7
|
+
tigerdatalab clean sales.csv
|
|
8
|
+
tigerdatalab report sales.csv
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from .. import core
|
|
16
|
+
from ..exceptions import TigerDataLabError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
parser = argparse.ArgumentParser(prog="tigerdatalab", description="TigerDataLab CLI")
|
|
21
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
22
|
+
|
|
23
|
+
p_analyze = sub.add_parser("analyze", help="Run full analysis and print summary")
|
|
24
|
+
p_analyze.add_argument("path")
|
|
25
|
+
|
|
26
|
+
p_dash = sub.add_parser("dashboard", help="Generate the interactive dashboard")
|
|
27
|
+
p_dash.add_argument("path")
|
|
28
|
+
p_dash.add_argument("-o", "--output", default="analysis/dashboard.html")
|
|
29
|
+
|
|
30
|
+
p_profile = sub.add_parser("profile", help="Print a data profile")
|
|
31
|
+
p_profile.add_argument("path")
|
|
32
|
+
|
|
33
|
+
p_quality = sub.add_parser("quality", help="Print a data quality report")
|
|
34
|
+
p_quality.add_argument("path")
|
|
35
|
+
|
|
36
|
+
p_clean = sub.add_parser("clean", help="Clean and export the dataset")
|
|
37
|
+
p_clean.add_argument("path")
|
|
38
|
+
p_clean.add_argument("-o", "--output", default="cleaned_data.xlsx")
|
|
39
|
+
|
|
40
|
+
p_report = sub.add_parser("report", help="Generate the full report bundle")
|
|
41
|
+
p_report.add_argument("path")
|
|
42
|
+
p_report.add_argument("-o", "--output", default="analysis")
|
|
43
|
+
|
|
44
|
+
args = parser.parse_args(argv)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
if args.command == "analyze":
|
|
48
|
+
core.analyze(args.path, verbose=True)
|
|
49
|
+
elif args.command == "dashboard":
|
|
50
|
+
result = core.analyze(args.path, verbose=False)
|
|
51
|
+
out = result.dashboard(args.output)
|
|
52
|
+
print(f"Dashboard written to: {out}")
|
|
53
|
+
elif args.command == "profile":
|
|
54
|
+
import json
|
|
55
|
+
print(json.dumps(core.profile(args.path), indent=2, default=str))
|
|
56
|
+
elif args.command == "quality":
|
|
57
|
+
import json
|
|
58
|
+
print(json.dumps(core.quality_check(args.path), indent=2, default=str))
|
|
59
|
+
elif args.command == "clean":
|
|
60
|
+
from ..reporting.exporters import save_cleaned_excel
|
|
61
|
+
df = core.clean_file(args.path)
|
|
62
|
+
out = save_cleaned_excel(args.output, df)
|
|
63
|
+
print(f"Cleaned data written to: {out}")
|
|
64
|
+
elif args.command == "report":
|
|
65
|
+
result = core.analyze(args.path, verbose=True)
|
|
66
|
+
outputs = result.report(args.output)
|
|
67
|
+
print("\nGenerated files:")
|
|
68
|
+
for k, v in outputs.items():
|
|
69
|
+
print(f" {k}: {v}")
|
|
70
|
+
return 0
|
|
71
|
+
except TigerDataLabError as e:
|
|
72
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
73
|
+
return 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
sys.exit(main())
|
tigerdatalab/config.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Global configuration and constants for TigerDataLab."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
__version__ = "3.0.4"
|
|
5
|
+
|
|
6
|
+
SUPPORTED_EXTENSIONS = [".csv", ".xlsx", ".xlsm", ".json", ".parquet", ".sql", ".db", ".sqlite", ".duckdb"]
|
|
7
|
+
|
|
8
|
+
# Row-count thresholds used to decide small / medium / large execution strategy.
|
|
9
|
+
SMALL_DATA_ROW_LIMIT = 100_000
|
|
10
|
+
MEDIUM_DATA_ROW_LIMIT = 5_000_000
|
|
11
|
+
|
|
12
|
+
# SQL keywords that TigerDataLab will never auto-execute.
|
|
13
|
+
DESTRUCTIVE_SQL_KEYWORDS = ["DROP", "TRUNCATE", "DELETE", "ALTER"]
|
|
14
|
+
|
|
15
|
+
# Semantic keyword dictionaries used by the column-semantics detector.
|
|
16
|
+
# Each business concept maps to a list of substrings matched against
|
|
17
|
+
# lower-cased, underscore/space-normalized column names.
|
|
18
|
+
SEMANTIC_KEYWORDS: dict[str, list[str]] = {
|
|
19
|
+
"revenue": ["revenue", "sales_amount", "sales", "amount", "gmv", "turnover",
|
|
20
|
+
"net_sales", "total_sales", "grand_total", "line_total",
|
|
21
|
+
"sale_amount", "total_amount", "order_value", "order_total",
|
|
22
|
+
"net_amount", "gross_sales", "sales_value"],
|
|
23
|
+
"profit": ["profit", "net_profit", "gross_profit", "profit_amount", "margin_amount",
|
|
24
|
+
"net_income", "earnings", "profit_value"],
|
|
25
|
+
"cost": ["cost", "cogs", "cost_price", "purchase_cost", "unit_cost",
|
|
26
|
+
"cost_amount", "buying_price", "purchase_price", "cogs_amount"],
|
|
27
|
+
"quantity": ["quantity", "qty", "units", "units_sold", "unit_count",
|
|
28
|
+
"qty_sold", "no_of_units", "units_purchased", "order_qty"],
|
|
29
|
+
"customer": ["customer_id", "customer", "buyer_id", "buyer", "client_id", "user_id",
|
|
30
|
+
"cust_id", "member_id", "account_id", "customer_name", "client_name"],
|
|
31
|
+
"product": ["product_id", "product_name", "product", "item", "sku", "item_name", "name",
|
|
32
|
+
"product_title", "item_code", "product_code"],
|
|
33
|
+
"category": ["category", "segment", "department", "product_category", "product_type",
|
|
34
|
+
"sub_category", "subcategory", "product_group"],
|
|
35
|
+
"date": ["date", "order_date", "transaction_date", "created_at", "timestamp", "order_time",
|
|
36
|
+
"invoice_date", "order_dt", "txn_date", "purchase_date", "created_date", "datetime"],
|
|
37
|
+
"discount": ["discount_pct", "discount_percent", "discount", "disc_pct", "disc_percent"],
|
|
38
|
+
"order": ["order_id", "transaction_id", "invoice_id", "invoice_no", "order_no",
|
|
39
|
+
"order_number", "txn_id", "receipt_no", "receipt_id", "bill_no", "bill_id"],
|
|
40
|
+
"price": ["unit_price", "selling_price", "price", "rate", "mrp", "list_price",
|
|
41
|
+
"retail_price", "sale_price"],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Keys checked in priority order (more specific first) to avoid, e.g.,
|
|
45
|
+
# "cost_price" being tagged as "price" instead of "cost".
|
|
46
|
+
SEMANTIC_PRIORITY = ["order", "customer", "product", "category", "date",
|
|
47
|
+
"discount", "cost", "profit", "revenue", "quantity", "price"]
|