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()