databubble 0.2.0__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.
- databubble/__init__.py +33 -0
- databubble/client.py +204 -0
- databubble/exceptions.py +47 -0
- databubble/journeys.py +359 -0
- databubble/memory.py +212 -0
- databubble/models.py +244 -0
- databubble/skills.py +335 -0
- databubble-0.2.0.dist-info/METADATA +80 -0
- databubble-0.2.0.dist-info/RECORD +11 -0
- databubble-0.2.0.dist-info/WHEEL +5 -0
- databubble-0.2.0.dist-info/top_level.txt +1 -0
databubble/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# databubble/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
DataBubble SDK — Statistical Intelligence as a Service.
|
|
4
|
+
|
|
5
|
+
Quick start:
|
|
6
|
+
from databubble import DataBubble
|
|
7
|
+
db = DataBubble(api_key="dbk_...")
|
|
8
|
+
result = db.skills.univariate(df["price"])
|
|
9
|
+
print(result.summary)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from databubble.client import DataBubble
|
|
13
|
+
from databubble.models import SkillResult, MemoryResult, ReconciliationResult, JourneyResult
|
|
14
|
+
from databubble.exceptions import (
|
|
15
|
+
DataBubbleError, AuthError, ForbiddenError,
|
|
16
|
+
RateLimitError, SkillError, ServerError, SDKUsageError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__version__ = "0.2.0"
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DataBubble",
|
|
22
|
+
"SkillResult",
|
|
23
|
+
"MemoryResult",
|
|
24
|
+
"ReconciliationResult",
|
|
25
|
+
"JourneyResult",
|
|
26
|
+
"DataBubbleError",
|
|
27
|
+
"AuthError",
|
|
28
|
+
"ForbiddenError",
|
|
29
|
+
"RateLimitError",
|
|
30
|
+
"SkillError",
|
|
31
|
+
"ServerError",
|
|
32
|
+
"SDKUsageError",
|
|
33
|
+
]
|
databubble/client.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# databubble/client.py
|
|
2
|
+
"""
|
|
3
|
+
DataBubble — root client.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from databubble import DataBubble
|
|
7
|
+
db = DataBubble(api_key="dbk_...")
|
|
8
|
+
result = db.skills.univariate(df["price"])
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from databubble.exceptions import (
|
|
17
|
+
AuthError, ForbiddenError, RateLimitError,
|
|
18
|
+
SkillError, ServerError, DataBubbleError,
|
|
19
|
+
)
|
|
20
|
+
from databubble.skills import SkillsClient
|
|
21
|
+
from databubble.memory import MemoryClient
|
|
22
|
+
from databubble.journeys import JourneysClient
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.databubble.ai"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _HTTPClient:
|
|
29
|
+
"""
|
|
30
|
+
Thin HTTP client. Uses httpx if available, falls back to urllib.
|
|
31
|
+
Handles auth header injection and error mapping.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, api_key: str, base_url: str, timeout: float):
|
|
35
|
+
self._api_key = api_key
|
|
36
|
+
self._base_url = base_url.rstrip("/")
|
|
37
|
+
self._timeout = timeout
|
|
38
|
+
self._session = None
|
|
39
|
+
self._init_session()
|
|
40
|
+
|
|
41
|
+
def _init_session(self):
|
|
42
|
+
try:
|
|
43
|
+
import httpx
|
|
44
|
+
self._session = httpx.Client(
|
|
45
|
+
base_url=self._base_url,
|
|
46
|
+
headers={"X-API-Key": self._api_key},
|
|
47
|
+
timeout=self._timeout,
|
|
48
|
+
)
|
|
49
|
+
self._backend = "httpx"
|
|
50
|
+
except ImportError:
|
|
51
|
+
# Fall back to urllib — no session, headers injected per-request
|
|
52
|
+
self._backend = "urllib"
|
|
53
|
+
|
|
54
|
+
def _raise_for_status(self, status_code: int, body: dict):
|
|
55
|
+
# Route-level errors wrap in {"detail": {"error": ...}};
|
|
56
|
+
# middleware errors use flat {"error": ...}. Extract from both (M-9).
|
|
57
|
+
detail = body.get("detail") or {}
|
|
58
|
+
msg = (
|
|
59
|
+
(detail.get("error") if isinstance(detail, dict) else None)
|
|
60
|
+
or body.get("error")
|
|
61
|
+
or f"HTTP {status_code}"
|
|
62
|
+
)
|
|
63
|
+
if status_code == 401:
|
|
64
|
+
raise AuthError(msg, status_code, body)
|
|
65
|
+
if status_code == 403:
|
|
66
|
+
raise ForbiddenError(msg, status_code, body)
|
|
67
|
+
if status_code == 429:
|
|
68
|
+
raise RateLimitError(msg, status_code, body)
|
|
69
|
+
if status_code in (400, 422): # 422 was falling through to generic DataBubbleError (D-4)
|
|
70
|
+
raise SkillError(msg, status_code, body)
|
|
71
|
+
if status_code >= 500:
|
|
72
|
+
raise ServerError(f"Server error ({status_code}): {msg}", status_code, body)
|
|
73
|
+
if status_code >= 400:
|
|
74
|
+
raise DataBubbleError(msg, status_code, body)
|
|
75
|
+
|
|
76
|
+
def post_json(self, path: str, payload: dict) -> dict:
|
|
77
|
+
"""POST with JSON body. Returns parsed response dict."""
|
|
78
|
+
url = f"{self._base_url}{path}"
|
|
79
|
+
|
|
80
|
+
if self._backend == "httpx":
|
|
81
|
+
response = self._session.post(path, json=payload)
|
|
82
|
+
body = response.json()
|
|
83
|
+
self._raise_for_status(response.status_code, body)
|
|
84
|
+
return body
|
|
85
|
+
else:
|
|
86
|
+
import urllib.request, urllib.error
|
|
87
|
+
data = json.dumps(payload).encode()
|
|
88
|
+
req = urllib.request.Request(
|
|
89
|
+
url, data=data,
|
|
90
|
+
headers={
|
|
91
|
+
"Content-Type": "application/json",
|
|
92
|
+
"X-API-Key": self._api_key,
|
|
93
|
+
},
|
|
94
|
+
method="POST",
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
98
|
+
return json.loads(resp.read())
|
|
99
|
+
except urllib.error.HTTPError as e:
|
|
100
|
+
body = json.loads(e.read())
|
|
101
|
+
self._raise_for_status(e.code, body)
|
|
102
|
+
# _raise_for_status always raises for 4xx/5xx; this is a safety net
|
|
103
|
+
raise ServerError(f"Unexpected response ({e.code})", e.code, body)
|
|
104
|
+
|
|
105
|
+
def post_multipart(self, path: str, fields: dict, files) -> dict:
|
|
106
|
+
"""
|
|
107
|
+
POST with multipart form data. Returns parsed response dict.
|
|
108
|
+
|
|
109
|
+
files may be:
|
|
110
|
+
- dict: {field_name: (filename, data, content_type)} — single file per field
|
|
111
|
+
- list of (field_name, (filename, data, content_type)) — supports repeated field names
|
|
112
|
+
for list[UploadFile] parameters (M-5: memory_files needs repeated "memory_files" key)
|
|
113
|
+
"""
|
|
114
|
+
url = f"{self._base_url}{path}"
|
|
115
|
+
|
|
116
|
+
if self._backend == "httpx":
|
|
117
|
+
form_data = {k: v for k, v in fields.items() if v is not None}
|
|
118
|
+
# httpx accepts files as a list of (name, content) tuples for repeated keys
|
|
119
|
+
if isinstance(files, dict):
|
|
120
|
+
file_list = [(k, v) for k, v in files.items()]
|
|
121
|
+
else:
|
|
122
|
+
file_list = list(files)
|
|
123
|
+
response = self._session.post(path, data=form_data, files=file_list)
|
|
124
|
+
body = response.json()
|
|
125
|
+
self._raise_for_status(response.status_code, body)
|
|
126
|
+
return body
|
|
127
|
+
else:
|
|
128
|
+
raise DataBubbleError(
|
|
129
|
+
"Multipart upload requires httpx. "
|
|
130
|
+
"Install with: pip install httpx"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def close(self):
|
|
134
|
+
if self._backend == "httpx" and self._session:
|
|
135
|
+
self._session.close()
|
|
136
|
+
|
|
137
|
+
def __enter__(self):
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(self, *args):
|
|
141
|
+
self.close()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class DataBubble:
|
|
145
|
+
"""
|
|
146
|
+
DataBubble API client.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
api_key: Your API key (starts with dbk_). Get one at databubble.ai.
|
|
150
|
+
base_url: API base URL. Defaults to https://api.databubble.ai.
|
|
151
|
+
Override for local development: http://localhost:8000
|
|
152
|
+
timeout: Request timeout in seconds. Default 60.
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
from databubble import DataBubble
|
|
156
|
+
db = DataBubble(api_key="dbk_...")
|
|
157
|
+
|
|
158
|
+
# Single-column skill
|
|
159
|
+
result = db.skills.univariate(df["price"])
|
|
160
|
+
print(result.summary)
|
|
161
|
+
print(result.warnings)
|
|
162
|
+
|
|
163
|
+
# Whole-dataset skill
|
|
164
|
+
result = db.skills.missing_values(df)
|
|
165
|
+
|
|
166
|
+
# Memory workflow
|
|
167
|
+
mem = db.memory.export(df, label="POS data June 2026")
|
|
168
|
+
mem.save("pos_memory.json")
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
api_key: Optional[str] = None,
|
|
174
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
175
|
+
timeout: float = 60.0,
|
|
176
|
+
):
|
|
177
|
+
import os
|
|
178
|
+
resolved_key = api_key or os.environ.get("DATABUBBLE_API_KEY", "")
|
|
179
|
+
if not resolved_key:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"API key required. Pass api_key= or set DATABUBBLE_API_KEY env var. "
|
|
182
|
+
"Get a key at databubble.ai."
|
|
183
|
+
)
|
|
184
|
+
if not resolved_key.startswith("dbk_"):
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"Invalid API key format. Keys start with 'dbk_'. Got: {resolved_key[:8]}..."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
self._http = _HTTPClient(resolved_key, base_url, timeout)
|
|
190
|
+
self.skills = SkillsClient(self._http)
|
|
191
|
+
self.memory = MemoryClient(self._http)
|
|
192
|
+
self.journeys = JourneysClient(self._http)
|
|
193
|
+
|
|
194
|
+
def close(self):
|
|
195
|
+
self._http.close()
|
|
196
|
+
|
|
197
|
+
def __enter__(self):
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
def __exit__(self, *args):
|
|
201
|
+
self.close()
|
|
202
|
+
|
|
203
|
+
def __repr__(self):
|
|
204
|
+
return f"DataBubble(base_url='{self._http._base_url}')"
|
databubble/exceptions.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# databubble/exceptions.py
|
|
2
|
+
"""
|
|
3
|
+
Typed exceptions for the DataBubble SDK.
|
|
4
|
+
Every HTTP error from the API maps to a specific exception class
|
|
5
|
+
so callers can handle them explicitly without parsing status codes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DataBubbleError(Exception):
|
|
10
|
+
"""Base exception for all SDK errors."""
|
|
11
|
+
def __init__(self, message: str, status_code: int = 0, response_body: dict = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.status_code = status_code
|
|
14
|
+
self.response_body = response_body or {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthError(DataBubbleError):
|
|
18
|
+
"""Invalid or missing API key. HTTP 401."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ForbiddenError(DataBubbleError):
|
|
23
|
+
"""Skill not available on this tier. HTTP 403."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RateLimitError(DataBubbleError):
|
|
28
|
+
"""Monthly call limit reached. HTTP 429."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SkillError(DataBubbleError):
|
|
33
|
+
"""Skill executed but returned an error (bad input, halted, etc). HTTP 400."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ServerError(DataBubbleError):
|
|
38
|
+
"""Unexpected server error. HTTP 5xx."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SDKUsageError(Exception):
|
|
43
|
+
"""
|
|
44
|
+
Raised for incorrect SDK usage — wrong argument types, missing required args.
|
|
45
|
+
Not an API error — never reaches the server.
|
|
46
|
+
"""
|
|
47
|
+
pass
|
databubble/journeys.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# databubble/journeys.py
|
|
2
|
+
"""
|
|
3
|
+
JourneysClient — typed methods for DataBubble journey endpoints.
|
|
4
|
+
|
|
5
|
+
Journey endpoints require business or enterprise tier.
|
|
6
|
+
Each method runs a complete end-to-end analytical workflow
|
|
7
|
+
in a single API call and returns a typed JourneyResult.
|
|
8
|
+
|
|
9
|
+
Column map rules (same for all journeys):
|
|
10
|
+
- Keys are role names defined by the journey (e.g. "price_col")
|
|
11
|
+
- Values are actual column names in your DataFrame
|
|
12
|
+
- The SDK builds the row-oriented data payload automatically
|
|
13
|
+
|
|
14
|
+
All methods accept a pd.DataFrame and keyword args for the column map.
|
|
15
|
+
The SDK serialises the DataFrame to the row-oriented JSON format the
|
|
16
|
+
API expects — callers never need to think about the wire format.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Optional
|
|
22
|
+
from databubble.models import JourneyResult
|
|
23
|
+
from databubble.exceptions import SDKUsageError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _df_to_rows_payload(df, columns: list[str]) -> dict:
|
|
27
|
+
"""
|
|
28
|
+
Serialise selected DataFrame columns to row-oriented JSON payload.
|
|
29
|
+
NaN → None (JSON has no NaN concept).
|
|
30
|
+
"""
|
|
31
|
+
rows = []
|
|
32
|
+
subset = df[columns]
|
|
33
|
+
for _, row in subset.iterrows():
|
|
34
|
+
rows.append([
|
|
35
|
+
None if (v != v) else v # NaN check without numpy
|
|
36
|
+
for v in row.tolist()
|
|
37
|
+
])
|
|
38
|
+
return {
|
|
39
|
+
"columns": columns,
|
|
40
|
+
"rows": rows,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_journey_result(response: dict, journey_type: str) -> JourneyResult:
|
|
45
|
+
"""Build a JourneyResult from the API response dict."""
|
|
46
|
+
result = response.get("result", {})
|
|
47
|
+
meta = response.get("_meta", {})
|
|
48
|
+
|
|
49
|
+
# Driver-specific fields
|
|
50
|
+
selection = result.get("selection_output", {})
|
|
51
|
+
recommended = selection.get("recommended", []) or result.get("recommended", [])
|
|
52
|
+
caution = selection.get("caution", []) or result.get("caution", [])
|
|
53
|
+
excluded = selection.get("excluded", []) or result.get("excluded", [])
|
|
54
|
+
|
|
55
|
+
return JourneyResult(
|
|
56
|
+
journey_type=journey_type,
|
|
57
|
+
halted=result.get("halted", False),
|
|
58
|
+
halt_reason=result.get("halt_reason"),
|
|
59
|
+
primary_estimate=result.get("primary_estimate"),
|
|
60
|
+
plain_english_summary=result.get("plain_english_summary", ""),
|
|
61
|
+
warnings=result.get("warnings", []),
|
|
62
|
+
assumptions_met=result.get("assumptions_met"),
|
|
63
|
+
adj_r_squared=result.get("adj_r_squared"),
|
|
64
|
+
revenue_implication=result.get("revenue_implication"),
|
|
65
|
+
recommended=recommended if isinstance(recommended, list) else [],
|
|
66
|
+
caution=caution if isinstance(caution, list) else [],
|
|
67
|
+
excluded=excluded if isinstance(excluded, list) else [],
|
|
68
|
+
tier=meta.get("tier"),
|
|
69
|
+
key_prefix=meta.get("key_prefix"),
|
|
70
|
+
raw=response,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _require_dataframe(data, method: str):
|
|
75
|
+
try:
|
|
76
|
+
import pandas as pd
|
|
77
|
+
except ImportError:
|
|
78
|
+
raise SDKUsageError("pandas is required. pip install pandas")
|
|
79
|
+
if not isinstance(data, pd.DataFrame):
|
|
80
|
+
raise SDKUsageError(
|
|
81
|
+
f"db.journeys.{method}() requires a pd.DataFrame. "
|
|
82
|
+
f"Got {type(data).__name__}."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _require_col(df, col: str, arg_name: str, method: str) -> str:
|
|
87
|
+
if col not in df.columns:
|
|
88
|
+
raise SDKUsageError(
|
|
89
|
+
f"db.journeys.{method}(): {arg_name}='{col}' not found in DataFrame. "
|
|
90
|
+
f"Available columns: {list(df.columns)}"
|
|
91
|
+
)
|
|
92
|
+
return col
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _require_col_list(df, cols, arg_name: str, method: str) -> list[str]:
|
|
96
|
+
if isinstance(cols, str):
|
|
97
|
+
cols = [cols]
|
|
98
|
+
missing = [c for c in cols if c not in df.columns]
|
|
99
|
+
if missing:
|
|
100
|
+
raise SDKUsageError(
|
|
101
|
+
f"db.journeys.{method}(): {arg_name} contains columns not in DataFrame: "
|
|
102
|
+
f"{missing}. Available: {list(df.columns)}"
|
|
103
|
+
)
|
|
104
|
+
return list(cols)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class JourneysClient:
|
|
108
|
+
def __init__(self, http_client):
|
|
109
|
+
self._http = http_client
|
|
110
|
+
|
|
111
|
+
def _call(self, endpoint: str, payload: dict, journey_type: str) -> JourneyResult:
|
|
112
|
+
response = self._http.post_json(f"/v1/journeys/{endpoint}", payload)
|
|
113
|
+
return _parse_journey_result(response, journey_type)
|
|
114
|
+
|
|
115
|
+
# -----------------------------------------------------------------------
|
|
116
|
+
# Elasticity
|
|
117
|
+
# -----------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def elasticity(
|
|
120
|
+
self,
|
|
121
|
+
df,
|
|
122
|
+
price_col: str,
|
|
123
|
+
sales_col: str,
|
|
124
|
+
confounder_cols: Optional[list[str]] = None,
|
|
125
|
+
) -> JourneyResult:
|
|
126
|
+
"""
|
|
127
|
+
Price elasticity of demand — log-log regression with full assumption checking.
|
|
128
|
+
|
|
129
|
+
Detects non-linearity, applies log-log transformation if warranted,
|
|
130
|
+
runs OLS, checks RESET + Durbin-Watson + normality, applies HAC
|
|
131
|
+
standard errors when autocorrelation detected, and surfaces a plain-
|
|
132
|
+
English revenue implication.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
df: DataFrame containing price, sales, and any confounders.
|
|
136
|
+
price_col: Column name for the price variable (predictor).
|
|
137
|
+
sales_col: Column name for the sales/demand variable (outcome).
|
|
138
|
+
confounder_cols: Optional list of confounder column names
|
|
139
|
+
(promotions, regional dummies, seasonality flags).
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
JourneyResult with:
|
|
143
|
+
primary_estimate — elasticity coefficient β (log-log)
|
|
144
|
+
revenue_implication — plain-English revenue direction
|
|
145
|
+
assumptions_met — False when RESET, DW, or normality fails
|
|
146
|
+
is_reliable() — quick gate: not halted AND assumptions_met
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
result = db.journeys.elasticity(
|
|
150
|
+
df,
|
|
151
|
+
price_col="price",
|
|
152
|
+
sales_col="revenue",
|
|
153
|
+
confounder_cols=["region", "promo_flag"],
|
|
154
|
+
)
|
|
155
|
+
if result.is_reliable():
|
|
156
|
+
print(result.revenue_implication)
|
|
157
|
+
else:
|
|
158
|
+
print(result.warnings)
|
|
159
|
+
|
|
160
|
+
Tier: Business and Enterprise only.
|
|
161
|
+
"""
|
|
162
|
+
_require_dataframe(df, "elasticity")
|
|
163
|
+
_require_col(df, price_col, "price_col", "elasticity")
|
|
164
|
+
_require_col(df, sales_col, "sales_col", "elasticity")
|
|
165
|
+
confounders = confounder_cols or []
|
|
166
|
+
if confounders:
|
|
167
|
+
_require_col_list(df, confounders, "confounder_cols", "elasticity")
|
|
168
|
+
|
|
169
|
+
all_cols = list(dict.fromkeys([price_col, sales_col] + confounders))
|
|
170
|
+
payload = {
|
|
171
|
+
"column_map": {
|
|
172
|
+
"price_col": price_col,
|
|
173
|
+
"sales_col": sales_col,
|
|
174
|
+
"confounder_cols": confounders,
|
|
175
|
+
},
|
|
176
|
+
"data": _df_to_rows_payload(df, all_cols),
|
|
177
|
+
"options": {},
|
|
178
|
+
}
|
|
179
|
+
return self._call("elasticity", payload, "elasticity")
|
|
180
|
+
|
|
181
|
+
# -----------------------------------------------------------------------
|
|
182
|
+
# Driver analysis
|
|
183
|
+
# -----------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def driver(
|
|
186
|
+
self,
|
|
187
|
+
df,
|
|
188
|
+
outcome_col: str,
|
|
189
|
+
candidate_cols: list[str],
|
|
190
|
+
) -> JourneyResult:
|
|
191
|
+
"""
|
|
192
|
+
Driver analysis — which variables drive the outcome?
|
|
193
|
+
|
|
194
|
+
Screens candidates for signal, collinearity, and leakage.
|
|
195
|
+
Elects one representative from each collinear cluster.
|
|
196
|
+
Returns recommended, caution, and excluded lists with reasons.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
df: DataFrame containing outcome and all candidates.
|
|
200
|
+
outcome_col: Column name for the variable to explain.
|
|
201
|
+
candidate_cols: List of candidate predictor column names.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
JourneyResult with:
|
|
205
|
+
recommended — predictors with clean signal
|
|
206
|
+
caution — predictors with collinearity or leakage concerns
|
|
207
|
+
excluded — predictors without sufficient signal
|
|
208
|
+
primary_estimate — effect size of top recommended driver
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
result = db.journeys.driver(
|
|
212
|
+
df,
|
|
213
|
+
outcome_col="sales",
|
|
214
|
+
candidate_cols=["price", "promotion", "region", "advertising"],
|
|
215
|
+
)
|
|
216
|
+
print(result.recommended)
|
|
217
|
+
print(result.caution)
|
|
218
|
+
print(result.primary_estimate)
|
|
219
|
+
|
|
220
|
+
Tier: Business and Enterprise only.
|
|
221
|
+
"""
|
|
222
|
+
_require_dataframe(df, "driver")
|
|
223
|
+
_require_col(df, outcome_col, "outcome_col", "driver")
|
|
224
|
+
candidates = _require_col_list(df, candidate_cols, "candidate_cols", "driver")
|
|
225
|
+
|
|
226
|
+
all_cols = list(dict.fromkeys([outcome_col] + candidates))
|
|
227
|
+
payload = {
|
|
228
|
+
"column_map": {
|
|
229
|
+
"outcome_col": outcome_col,
|
|
230
|
+
"candidate_cols": candidates,
|
|
231
|
+
},
|
|
232
|
+
"data": _df_to_rows_payload(df, all_cols),
|
|
233
|
+
"options": {},
|
|
234
|
+
}
|
|
235
|
+
return self._call("driver", payload, "driver")
|
|
236
|
+
|
|
237
|
+
# -----------------------------------------------------------------------
|
|
238
|
+
# Segmentation
|
|
239
|
+
# -----------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def segmentation(
|
|
242
|
+
self,
|
|
243
|
+
df,
|
|
244
|
+
feature_cols: list[str],
|
|
245
|
+
label_col: Optional[str] = None,
|
|
246
|
+
) -> JourneyResult:
|
|
247
|
+
"""
|
|
248
|
+
Customer segmentation — discovery or classification mode.
|
|
249
|
+
|
|
250
|
+
Discovery mode (no label_col): finds natural groups via clustering.
|
|
251
|
+
Classification mode (with label_col): trains a classifier to predict
|
|
252
|
+
pre-defined segments and scores new observations.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
df: DataFrame containing features and optionally labels.
|
|
256
|
+
feature_cols: Columns to use as segmentation features.
|
|
257
|
+
label_col: Pre-defined segment column (classification mode).
|
|
258
|
+
Omit for discovery mode.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
JourneyResult with:
|
|
262
|
+
Discovery: silhouette score, cluster profiles in plain_english_summary
|
|
263
|
+
Classification: accuracy, F1, confusion matrix summary in plain_english_summary
|
|
264
|
+
|
|
265
|
+
Example:
|
|
266
|
+
# Discovery
|
|
267
|
+
result = db.journeys.segmentation(
|
|
268
|
+
df, feature_cols=["recency", "frequency", "spend"]
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Classification
|
|
272
|
+
result = db.journeys.segmentation(
|
|
273
|
+
df,
|
|
274
|
+
feature_cols=["recency", "frequency", "spend"],
|
|
275
|
+
label_col="segment",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
Tier: Business and Enterprise only.
|
|
279
|
+
"""
|
|
280
|
+
_require_dataframe(df, "segmentation")
|
|
281
|
+
features = _require_col_list(df, feature_cols, "feature_cols", "segmentation")
|
|
282
|
+
if label_col:
|
|
283
|
+
_require_col(df, label_col, "label_col", "segmentation")
|
|
284
|
+
|
|
285
|
+
all_cols = list(dict.fromkeys(
|
|
286
|
+
features + ([label_col] if label_col else [])
|
|
287
|
+
))
|
|
288
|
+
column_map = {"feature_cols": features}
|
|
289
|
+
if label_col:
|
|
290
|
+
column_map["label_col"] = label_col
|
|
291
|
+
|
|
292
|
+
payload = {
|
|
293
|
+
"column_map": column_map,
|
|
294
|
+
"data": _df_to_rows_payload(df, all_cols),
|
|
295
|
+
"options": {},
|
|
296
|
+
}
|
|
297
|
+
return self._call("segmentation", payload, "segmentation")
|
|
298
|
+
|
|
299
|
+
# -----------------------------------------------------------------------
|
|
300
|
+
# Time series
|
|
301
|
+
# -----------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def time_series(
|
|
304
|
+
self,
|
|
305
|
+
df,
|
|
306
|
+
date_col: str,
|
|
307
|
+
value_col: str,
|
|
308
|
+
objective: str = "forecast",
|
|
309
|
+
) -> JourneyResult:
|
|
310
|
+
"""
|
|
311
|
+
Time series forecasting and decomposition.
|
|
312
|
+
|
|
313
|
+
Validates the time index, fills gaps, selects between ETS and SARIMA
|
|
314
|
+
via AICc, produces prediction intervals from the model's native PI
|
|
315
|
+
method, and validates against a seasonal naïve benchmark via walk-
|
|
316
|
+
forward cross-validation.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
df: DataFrame with date and value columns.
|
|
320
|
+
date_col: Column name for the date/timestamp variable.
|
|
321
|
+
value_col: Column name for the series to model.
|
|
322
|
+
objective: "forecast" (default) or "decompose".
|
|
323
|
+
Decompose returns trend, seasonal, and remainder components.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
JourneyResult with:
|
|
327
|
+
Forecast: primary_estimate = horizon-1 point forecast,
|
|
328
|
+
plain_english_summary includes PI and benchmark comparison
|
|
329
|
+
Decompose: plain_english_summary describes trend and seasonal pattern
|
|
330
|
+
|
|
331
|
+
Example:
|
|
332
|
+
result = db.journeys.time_series(
|
|
333
|
+
df,
|
|
334
|
+
date_col="week",
|
|
335
|
+
value_col="weekly_sales",
|
|
336
|
+
objective="forecast",
|
|
337
|
+
)
|
|
338
|
+
print(result.primary_estimate) # h=1 point forecast
|
|
339
|
+
print(result.plain_english_summary)
|
|
340
|
+
|
|
341
|
+
Tier: Business and Enterprise only.
|
|
342
|
+
"""
|
|
343
|
+
_require_dataframe(df, "time_series")
|
|
344
|
+
_require_col(df, date_col, "date_col", "time_series")
|
|
345
|
+
_require_col(df, value_col, "value_col", "time_series")
|
|
346
|
+
if objective not in ("forecast", "decompose"):
|
|
347
|
+
raise SDKUsageError(
|
|
348
|
+
f"objective must be 'forecast' or 'decompose'. Got: '{objective}'"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
payload = {
|
|
352
|
+
"column_map": {
|
|
353
|
+
"date_col": date_col,
|
|
354
|
+
"value_col": value_col,
|
|
355
|
+
},
|
|
356
|
+
"data": _df_to_rows_payload(df, [date_col, value_col]),
|
|
357
|
+
"options": {"objective": objective},
|
|
358
|
+
}
|
|
359
|
+
return self._call("time_series", payload, "time_series")
|