counterfact 0.1.0__tar.gz
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.
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rudraneel Das
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: counterfact
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Did your change really work? Causal effect of a price change, campaign or policy on any time series.
|
|
5
|
+
Project-URL: Homepage, https://counterfact-site.vercel.app
|
|
6
|
+
Project-URL: Open model, https://huggingface.co/Rudraneel93/counterfact-v2
|
|
7
|
+
Author: Rudraneel Das
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: causal impact,causal inference,counterfactual,marketing measurement,pricing,time series
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Provides-Extra: pandas
|
|
16
|
+
Requires-Dist: pandas>=1.3; extra == 'pandas'
|
|
17
|
+
Provides-Extra: plot
|
|
18
|
+
Requires-Dist: matplotlib>=3.5; extra == 'plot'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# counterfact
|
|
22
|
+
|
|
23
|
+
**Did your change really work?** Measure the real effect of a price change, campaign, launch or policy on any time series. You get an honest likely range, and a plain "can't tell yet" when the data can't support a claim.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install counterfact # add [pandas] and [plot] for DataFrames and charts
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import pandas as pd
|
|
31
|
+
from counterfact import Client
|
|
32
|
+
|
|
33
|
+
cf = Client(api_key="YOUR_KEY") # or set COUNTERFACT_API_KEY
|
|
34
|
+
df = pd.read_csv("weekly_sales.csv")
|
|
35
|
+
r = cf.analyze(df, metric="sales", change_at="2025-10-06", time_col="week",
|
|
36
|
+
comparisons=["sales_other_region", "category_sales"])
|
|
37
|
+
print(r)
|
|
38
|
+
# sales, change at 2025-10-06: WORKED
|
|
39
|
+
# effect per period: +40.9 (+9.5%) likely range: +18.6 to +63.2
|
|
40
|
+
# checks: fake change dates flagged 0 of 3, verdict stable when dropping a comparison 3 of 3
|
|
41
|
+
print(r.explanation) # plain-language summary
|
|
42
|
+
r.plot() # needs matplotlib
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What you get back
|
|
46
|
+
- `verdict`: "worked", "hurt" or "can't tell yet"
|
|
47
|
+
- `effect` per period, `effect_percent`, `likely_range` (holds about 9 times in 10), `total_effect`
|
|
48
|
+
- `quick_before_after`: the naive comparison, for contrast
|
|
49
|
+
- `robustness`: fake-change-date checks and leave-one-comparison-out checks
|
|
50
|
+
- `counterfactual`: what would most likely have happened without the change, period by period
|
|
51
|
+
|
|
52
|
+
## Keys
|
|
53
|
+
- **Counterfact Pro** (₹1,999/month) includes 2,000 API calls: [counterfact-site.vercel.app/#pricing](https://counterfact-site.vercel.app/#pricing). Your Pro access code is your API key. `cf.usage()` shows calls left.
|
|
54
|
+
- **RapidAPI:** `Client(rapidapi_key="...")`, billed through RapidAPI.
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
Counterfact is a transformer pretrained on millions of synthetic worlds where the true cause and effect is known, and calibrated on 1,200 real Indian and US data windows. An open research version is on Hugging Face: [Rudraneel93/counterfact-v2](https://huggingface.co/Rudraneel93/counterfact-v2).
|
|
58
|
+
|
|
59
|
+
## Licence
|
|
60
|
+
This client is MIT licensed. Use of the API is subject to the Counterfact terms.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# counterfact
|
|
2
|
+
|
|
3
|
+
**Did your change really work?** Measure the real effect of a price change, campaign, launch or policy on any time series. You get an honest likely range, and a plain "can't tell yet" when the data can't support a claim.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install counterfact # add [pandas] and [plot] for DataFrames and charts
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from counterfact import Client
|
|
12
|
+
|
|
13
|
+
cf = Client(api_key="YOUR_KEY") # or set COUNTERFACT_API_KEY
|
|
14
|
+
df = pd.read_csv("weekly_sales.csv")
|
|
15
|
+
r = cf.analyze(df, metric="sales", change_at="2025-10-06", time_col="week",
|
|
16
|
+
comparisons=["sales_other_region", "category_sales"])
|
|
17
|
+
print(r)
|
|
18
|
+
# sales, change at 2025-10-06: WORKED
|
|
19
|
+
# effect per period: +40.9 (+9.5%) likely range: +18.6 to +63.2
|
|
20
|
+
# checks: fake change dates flagged 0 of 3, verdict stable when dropping a comparison 3 of 3
|
|
21
|
+
print(r.explanation) # plain-language summary
|
|
22
|
+
r.plot() # needs matplotlib
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What you get back
|
|
26
|
+
- `verdict`: "worked", "hurt" or "can't tell yet"
|
|
27
|
+
- `effect` per period, `effect_percent`, `likely_range` (holds about 9 times in 10), `total_effect`
|
|
28
|
+
- `quick_before_after`: the naive comparison, for contrast
|
|
29
|
+
- `robustness`: fake-change-date checks and leave-one-comparison-out checks
|
|
30
|
+
- `counterfactual`: what would most likely have happened without the change, period by period
|
|
31
|
+
|
|
32
|
+
## Keys
|
|
33
|
+
- **Counterfact Pro** (₹1,999/month) includes 2,000 API calls: [counterfact-site.vercel.app/#pricing](https://counterfact-site.vercel.app/#pricing). Your Pro access code is your API key. `cf.usage()` shows calls left.
|
|
34
|
+
- **RapidAPI:** `Client(rapidapi_key="...")`, billed through RapidAPI.
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
Counterfact is a transformer pretrained on millions of synthetic worlds where the true cause and effect is known, and calibrated on 1,200 real Indian and US data windows. An open research version is on Hugging Face: [Rudraneel93/counterfact-v2](https://huggingface.co/Rudraneel93/counterfact-v2).
|
|
38
|
+
|
|
39
|
+
## Licence
|
|
40
|
+
This client is MIT licensed. Use of the API is subject to the Counterfact terms.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "counterfact"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Did your change really work? Causal effect of a price change, campaign or policy on any time series."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Rudraneel Das" }]
|
|
13
|
+
keywords = ["causal inference", "time series", "causal impact", "counterfactual", "marketing measurement", "pricing"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
18
|
+
]
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
pandas = ["pandas>=1.3"]
|
|
23
|
+
plot = ["matplotlib>=3.5"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://counterfact-site.vercel.app"
|
|
27
|
+
"Open model" = "https://huggingface.co/Rudraneel93/counterfact-v2"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Counterfact: did your change really work?
|
|
2
|
+
|
|
3
|
+
from counterfact import Client
|
|
4
|
+
cf = Client() # reads COUNTERFACT_API_KEY
|
|
5
|
+
r = cf.analyze(df, metric="sales", change_at="2025-10-06", time_col="week")
|
|
6
|
+
print(r) # verdict, effect, likely range, checks
|
|
7
|
+
"""
|
|
8
|
+
from .client import Client, CounterfactError, Result
|
|
9
|
+
|
|
10
|
+
__all__ = ["Client", "Result", "CounterfactError"]
|
|
11
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Thin client for the Counterfact API (standard library only; pandas and matplotlib optional)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
DEFAULT_URL = "https://mastyf-support--counterfact-api-api.us-east.modal.run"
|
|
12
|
+
RAPIDAPI_HOST = "counterfact.p.rapidapi.com"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CounterfactError(Exception):
|
|
16
|
+
"""Raised when the API refuses a request (bad input, missing or used-up key, ...)."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, status: int, message: str):
|
|
19
|
+
super().__init__(f"[{status}] {message}")
|
|
20
|
+
self.status, self.message = status, message
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Result:
|
|
25
|
+
"""The answer for one change. Effects are per period, in the metric's own units."""
|
|
26
|
+
metric: str
|
|
27
|
+
change_at: str
|
|
28
|
+
verdict: str # "worked" | "hurt" | "can't tell yet"
|
|
29
|
+
effect: float # per period
|
|
30
|
+
effect_percent: float | None
|
|
31
|
+
likely_range: tuple[float, float] # holds about 9 times in 10
|
|
32
|
+
total_effect: float
|
|
33
|
+
chance_positive: float
|
|
34
|
+
quick_before_after: dict
|
|
35
|
+
robustness: dict
|
|
36
|
+
comparisons_used: list[str]
|
|
37
|
+
explanation: str
|
|
38
|
+
counterfactual: dict
|
|
39
|
+
raw: dict = field(repr=False, default_factory=dict)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_api(cls, d: dict) -> "Result":
|
|
43
|
+
s = d["summary"]
|
|
44
|
+
return cls(metric=s["metric"], change_at=s["change_at"], verdict=s["verdict"],
|
|
45
|
+
effect=s["effect_per_period"], effect_percent=s["effect_percent"],
|
|
46
|
+
likely_range=tuple(s["likely_range_per_period"]), total_effect=s["total_effect_over_period"],
|
|
47
|
+
chance_positive=s["chance_effect_is_positive"], quick_before_after=s["quick_before_after"],
|
|
48
|
+
robustness=s["robustness"], comparisons_used=s["comparisons_used"],
|
|
49
|
+
explanation=d.get("explanation", ""), counterfactual=d.get("counterfactual", {}), raw=d)
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
pct = f" ({self.effect_percent:+.1f}%)" if self.effect_percent is not None else ""
|
|
53
|
+
lo, hi = self.likely_range
|
|
54
|
+
return (f"{self.metric}, change at {self.change_at}: {self.verdict.upper()}\n"
|
|
55
|
+
f" effect per period: {self.effect:+,.3g}{pct} likely range: {lo:+,.3g} to {hi:+,.3g}\n"
|
|
56
|
+
f" checks: fake change dates flagged {self.robustness.get('fake_dates_flagged')}, "
|
|
57
|
+
f"verdict stable when dropping a comparison {self.robustness.get('verdict_stable_when_dropping')}")
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict:
|
|
60
|
+
return self.raw
|
|
61
|
+
|
|
62
|
+
def plot(self, ax=None):
|
|
63
|
+
"""Plot what happened vs what would have happened anyway (needs matplotlib)."""
|
|
64
|
+
import matplotlib.pyplot as plt
|
|
65
|
+
cf = self.counterfactual
|
|
66
|
+
ax = ax or plt.subplots(figsize=(9, 4))[1]
|
|
67
|
+
x = list(range(len(cf["observed"])))
|
|
68
|
+
ax.plot(x, cf["observed"], color="#0891b2", lw=2, label="What happened")
|
|
69
|
+
ax.plot(x, cf["median"], "--", color="#7c5cff", lw=2, label="What would have happened anyway")
|
|
70
|
+
ax.fill_between(x, cf["lo90"], cf["hi90"], color="#7c5cff", alpha=0.18, label="Likely range")
|
|
71
|
+
step = max(1, len(x) // 8)
|
|
72
|
+
ax.set_xticks(x[::step], [str(v) for v in cf["index"][::step]], rotation=30, fontsize=8)
|
|
73
|
+
ax.set_title(f"{self.metric} after the change at {self.change_at}: {self.verdict}")
|
|
74
|
+
ax.legend(fontsize=8)
|
|
75
|
+
return ax
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Client:
|
|
79
|
+
"""Counterfact API client.
|
|
80
|
+
|
|
81
|
+
Client(api_key=...) a Counterfact Pro key (or set COUNTERFACT_API_KEY)
|
|
82
|
+
Client(rapidapi_key=...) a RapidAPI key (or set RAPIDAPI_KEY), billed through RapidAPI
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, api_key: str | None = None, rapidapi_key: str | None = None,
|
|
86
|
+
base_url: str | None = None, timeout: float = 120.0):
|
|
87
|
+
self.api_key = api_key or os.environ.get("COUNTERFACT_API_KEY")
|
|
88
|
+
self.rapidapi_key = rapidapi_key or os.environ.get("RAPIDAPI_KEY")
|
|
89
|
+
if self.rapidapi_key and not base_url:
|
|
90
|
+
base_url = f"https://{RAPIDAPI_HOST}"
|
|
91
|
+
self.base_url = (base_url or os.environ.get("COUNTERFACT_URL") or DEFAULT_URL).rstrip("/")
|
|
92
|
+
self.timeout = timeout
|
|
93
|
+
if not (self.api_key or self.rapidapi_key):
|
|
94
|
+
raise CounterfactError(401, "No key. Pass api_key=... (Counterfact Pro) or rapidapi_key=..., "
|
|
95
|
+
"or set COUNTERFACT_API_KEY. Get a key at https://counterfact-site.vercel.app/#pricing")
|
|
96
|
+
|
|
97
|
+
def _headers(self) -> dict:
|
|
98
|
+
h = {"content-type": "application/json", "user-agent": "counterfact-python/0.1.0"}
|
|
99
|
+
if self.rapidapi_key:
|
|
100
|
+
h.update({"x-rapidapi-key": self.rapidapi_key, "x-rapidapi-host": RAPIDAPI_HOST})
|
|
101
|
+
else:
|
|
102
|
+
h["x-api-key"] = self.api_key
|
|
103
|
+
return h
|
|
104
|
+
|
|
105
|
+
def _call(self, method: str, path: str, body: Any = None) -> dict:
|
|
106
|
+
data = None if body is None else json.dumps(body).encode()
|
|
107
|
+
req = urllib.request.Request(self.base_url + path, data=data, method=method, headers=self._headers())
|
|
108
|
+
try:
|
|
109
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
110
|
+
return json.loads(r.read())
|
|
111
|
+
except urllib.error.HTTPError as e:
|
|
112
|
+
try:
|
|
113
|
+
msg = json.loads(e.read()).get("detail", e.reason)
|
|
114
|
+
except Exception:
|
|
115
|
+
msg = str(e.reason)
|
|
116
|
+
raise CounterfactError(e.code, str(msg)) from None
|
|
117
|
+
|
|
118
|
+
def analyze(self, data, metric: str, change_at, comparisons: list[str] | None = None,
|
|
119
|
+
time_col: str | None = None, changed_column: str = "") -> Result:
|
|
120
|
+
"""Measure the effect of a change on `metric`.
|
|
121
|
+
|
|
122
|
+
data: a pandas DataFrame (rows in time order) or a dict {name: list of numbers}
|
|
123
|
+
change_at: first period after the change: a value from `time_col`, or a 1-based row number
|
|
124
|
+
comparisons: columns the change could NOT have affected (default: every other numeric column)
|
|
125
|
+
time_col: optional column with dates or period labels
|
|
126
|
+
"""
|
|
127
|
+
index = None
|
|
128
|
+
if hasattr(data, "to_dict") and hasattr(data, "columns"): # pandas DataFrame
|
|
129
|
+
df = data
|
|
130
|
+
if time_col:
|
|
131
|
+
index = [str(v) for v in df[time_col].tolist()]
|
|
132
|
+
series = {str(c): [float(v) for v in df[c].tolist()] for c in df.columns
|
|
133
|
+
if c != time_col and str(df[c].dtype)[:3] in ("int", "flo", "uin")}
|
|
134
|
+
else:
|
|
135
|
+
series = {str(k): [float(v) for v in vals] for k, vals in dict(data).items() if k != time_col}
|
|
136
|
+
if time_col and time_col in dict(data):
|
|
137
|
+
index = [str(v) for v in dict(data)[time_col]]
|
|
138
|
+
if metric not in series:
|
|
139
|
+
raise CounterfactError(400, f"No numeric column named {metric!r}. Available: {', '.join(series)}")
|
|
140
|
+
body = {"series": series, "metric": metric, "change_at": str(change_at),
|
|
141
|
+
"comparisons": comparisons, "changed_column": changed_column, "index": index}
|
|
142
|
+
return Result.from_api(self._call("POST", "/v2/analyze", body))
|
|
143
|
+
|
|
144
|
+
def usage(self) -> dict:
|
|
145
|
+
"""Plan, expiry and API calls left on a Counterfact Pro key."""
|
|
146
|
+
return self._call("GET", "/v2/usage")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import json, threading
|
|
2
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from counterfact import Client, CounterfactError
|
|
7
|
+
|
|
8
|
+
SUMMARY = {"metric": "sales", "change_at": "W81", "verdict": "worked", "effect_per_period": 40.9, "effect_percent": 9.5,
|
|
9
|
+
"likely_range_per_period": [18.6, 63.2], "total_effect_over_period": 982.0, "chance_effect_is_positive": 0.99,
|
|
10
|
+
"quick_before_after": {"difference": 52.1}, "comparisons_used": ["b"],
|
|
11
|
+
"robustness": {"fake_dates_flagged": "0 of 3", "verdict_stable_when_dropping": "n/a"}}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class H(BaseHTTPRequestHandler):
|
|
15
|
+
def log_message(self, *a): pass
|
|
16
|
+
def do_POST(self):
|
|
17
|
+
body = json.loads(self.rfile.read(int(self.headers["content-length"])))
|
|
18
|
+
if self.headers.get("x-api-key") != "good":
|
|
19
|
+
self.send_response(403); self.end_headers(); self.wfile.write(b'{"detail":"That access code isn\'t valid."}'); return
|
|
20
|
+
assert body["metric"] == "sales" and body["index"][0] == "W1" and "week" not in body["series"]
|
|
21
|
+
self.send_response(200); self.end_headers()
|
|
22
|
+
self.wfile.write(json.dumps({"summary": SUMMARY, "explanation": "It worked.", "counterfactual": {}}).encode())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def server():
|
|
27
|
+
s = HTTPServer(("127.0.0.1", 0), H)
|
|
28
|
+
threading.Thread(target=s.serve_forever, daemon=True).start()
|
|
29
|
+
yield f"http://127.0.0.1:{s.server_port}"
|
|
30
|
+
s.shutdown()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_analyze_dict_and_errors(server):
|
|
34
|
+
data = {"week": [f"W{i}" for i in range(1, 21)], "sales": list(range(20)), "b": list(range(20))}
|
|
35
|
+
r = Client(api_key="good", base_url=server).analyze(data, metric="sales", change_at="W15", time_col="week")
|
|
36
|
+
assert r.verdict == "worked" and r.likely_range == (18.6, 63.2) and "WORKED" in str(r)
|
|
37
|
+
with pytest.raises(CounterfactError) as e:
|
|
38
|
+
Client(api_key="bad", base_url=server).analyze(data, metric="sales", change_at="W15", time_col="week")
|
|
39
|
+
assert e.value.status == 403 and "isn't valid" in e.value.message
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_requires_key(monkeypatch):
|
|
43
|
+
monkeypatch.delenv("COUNTERFACT_API_KEY", raising=False); monkeypatch.delenv("RAPIDAPI_KEY", raising=False)
|
|
44
|
+
with pytest.raises(CounterfactError):
|
|
45
|
+
Client()
|