Abhijeet Mahapatra commited on
Commit
534e9ee
·
1 Parent(s): 45386d3

Fixe s for HF hosting

Browse files
Files changed (3) hide show
  1. README.md +1 -1
  2. app.py +0 -876
  3. server.py +1 -0
README.md CHANGED
@@ -4,7 +4,7 @@ colorFrom: blue
4
  colorTo: indigo
5
  sdk: gradio
6
  sdk_version: 6.18.0
7
- app_file: app.py
8
  pinned: false
9
  ---
10
 
 
4
  colorTo: indigo
5
  sdk: gradio
6
  sdk_version: 6.18.0
7
+ app_file: server.py
8
  pinned: false
9
  ---
10
 
app.py DELETED
@@ -1,876 +0,0 @@
1
- """
2
- dashboard_server.py
3
- ====================
4
- Iroha Financial Intelligence — gr.Server entry point.
5
-
6
- Architecture
7
- ------------
8
- gr.Server (extends FastAPI)
9
- ├── GET / → serves frontend/index.html
10
- ├── GET /static/* → serves frontend/{style.css, app.js} (StaticFiles)
11
-
12
- ├── @server.api run_inference → DoFlow / SCM causal query (via BACKEND_API)
13
-
14
- ├── GET /v2/health → health-check
15
-
16
- └── All existing /v2/* routers from main.py are included here too
17
- (so this server is a superset of main.py).
18
-
19
- Usage
20
- -----
21
- python dashboard_server.py
22
-
23
- Or with uvicorn:
24
- uvicorn dashboard_server:server --host 0.0.0.0 --port 7860 --reload
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- import os
30
- import sys
31
- import json
32
- import logging
33
- import urllib.error
34
- import urllib.parse
35
- import urllib.request
36
- from pathlib import Path
37
- import gradio as gr
38
- from typing import Any, Dict, List, Optional
39
- from dotenv import load_dotenv
40
-
41
- load_dotenv()
42
-
43
- BASE_DIR = Path(__file__).parent.resolve()
44
- if str(BASE_DIR) not in sys.path:
45
- sys.path.insert(0, str(BASE_DIR))
46
-
47
- # Also add the backend directory to sys.path so we can import 'app', 'causal', etc.
48
- BACKEND_DIR = (BASE_DIR.parent / "noisy_boy_backend").resolve()
49
- if BACKEND_DIR.exists() and str(BACKEND_DIR) not in sys.path:
50
- sys.path.insert(0, str(BACKEND_DIR))
51
-
52
- # ─────────────────────────────────────────────────────────────────────────────
53
- # Logging
54
- # ─────────────────────────────────────────────────────────────────────────────
55
- logging.basicConfig(
56
- level=logging.INFO,
57
- format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
58
- handlers=[logging.StreamHandler()],
59
- )
60
- logger = logging.getLogger("dashboard-server")
61
-
62
- # ─────────────────────────────────────────────────────────────────────────────
63
- # Backend URL
64
- # ─────────────────────────────────────────────────────────────────────────────
65
-
66
- _BACKEND_BASE_URL: str = os.environ.get("BACKEND_API_URL", "http://localhost:8000")
67
-
68
-
69
- # ─────────────────────────────────────────────────────────────────────────────
70
- # Public API
71
- # ─────────────────────────────────────────────────────────────────────────────
72
-
73
- def run_pipeline(
74
- ticker: str = "RELIANCE",
75
- threshold: float = 0.5,
76
- treatment: Optional[str] = None,
77
- outcome: Optional[str] = None,
78
- include_pywhyllm: bool = False,
79
- ) -> Dict[str, Any]:
80
- """
81
- Fetch the validated causal matrix for *ticker* from the backend API.
82
-
83
- Parameters
84
- ----------
85
- ticker : NSE symbol (e.g. RELIANCE, HDFCBANK)
86
- threshold : adjacency threshold for DAG construction
87
- treatment : optional treatment node for pywhyllm assumptions
88
- outcome : optional outcome node for pywhyllm assumptions
89
- include_pywhyllm: request pywhyllm assumption report from backend
90
-
91
- Returns
92
- -------
93
- dict with keys:
94
- nodes, adj_matrix, dag_adj, equations, data_level,
95
- topological_order, nodes_graph, links_graph
96
- Raises RuntimeError if the backend cannot be reached or returns an error.
97
- """
98
- params: dict = {"threshold": threshold}
99
- if treatment:
100
- params["treatment"] = treatment
101
- if outcome:
102
- params["outcome"] = outcome
103
- if include_pywhyllm:
104
- params["include_pywhyllm"] = "true"
105
-
106
- qs = urllib.parse.urlencode(params)
107
- url = f"{_BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker.upper()}?{qs}"
108
- logger.info("run_pipeline: fetching %s", url)
109
-
110
- try:
111
- with urllib.request.urlopen(url, timeout=60) as resp:
112
- raw = resp.read()
113
- except urllib.error.URLError as exc:
114
- raise RuntimeError(
115
- f"Could not reach backend at {_BACKEND_BASE_URL}. "
116
- f"Ensure noisy_boy_backend is running. Original error: {exc}"
117
- ) from exc
118
-
119
- payload = json.loads(raw)
120
-
121
- status = payload.get("status")
122
- if status == "not_found":
123
- raise RuntimeError(
124
- payload.get(
125
- "detail",
126
- f"No cached pipeline data for {ticker} on backend. "
127
- "Run the singular-causal pipeline on the backend first.",
128
- )
129
- )
130
- if status not in ("success", None, "ok"):
131
- raise RuntimeError(
132
- f"Backend returned unexpected status '{status}' for {ticker}. "
133
- f"Payload: {payload}"
134
- )
135
-
136
- # Build frontend-friendly graph representation
137
- nodes: List[str] = payload.get("nodes", [])
138
- adj_matrix = payload.get("adj_matrix", [])
139
- dag_adj = payload.get("dag_adj", [])
140
-
141
- nodes_graph = [{"id": n, "label": n} for n in nodes]
142
- links_graph = []
143
- for i, src in enumerate(nodes):
144
- for j, dst in enumerate(nodes):
145
- if i != j:
146
- try:
147
- score = float(adj_matrix[i][j])
148
- except (IndexError, TypeError, ValueError):
149
- score = 0.0
150
- if score >= threshold:
151
- links_graph.append({"source": src, "target": dst, "score": round(score, 4)})
152
-
153
- return {
154
- **payload,
155
- "nodes_graph": nodes_graph,
156
- "links_graph": links_graph,
157
- }
158
-
159
- # ─────────────────────────────────────────────────────────────────────────────
160
- # Helpers
161
- # ─────────────────────────────────────────────────────────────────────────────
162
-
163
- def _fetch_causal_matrix(
164
- ticker: str,
165
- treatment: Optional[str] = None,
166
- outcome: Optional[str] = None,
167
- include_pywhyllm: bool = False,
168
- threshold: float = 0.5,
169
- ) -> Optional[dict]:
170
- """
171
- Fetch the fully validated causal matrix from the backend API.
172
-
173
- Calls GET {BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker}
174
- and returns the parsed JSON payload, or None on failure.
175
-
176
- The payload contains:
177
- nodes — ordered list of node names
178
- adj_matrix — raw float adjacency matrix
179
- dag_adj — thresholded 0/1 DAG
180
- equations — per-node structural equations (coefficients, intercepts, residual_std)
181
- data_level — (T, N) time-series observations used to fit the SCM
182
- topological_order — nodes in topological traversal order
183
- pywhyllm_report — (optional) assumption analysis for treatment→outcome
184
- """
185
- import urllib.request
186
- import urllib.error
187
- import urllib.parse
188
-
189
- params: dict = {"threshold": threshold}
190
- if treatment:
191
- params["treatment"] = treatment
192
- if outcome:
193
- params["outcome"] = outcome
194
- if include_pywhyllm:
195
- params["include_pywhyllm"] = "true"
196
-
197
- query_string = urllib.parse.urlencode(params)
198
- url = f"{_BACKEND_BASE_URL}/v2/api/singular-causal/causal-matrix/{ticker.upper()}?{query_string}"
199
-
200
- try:
201
- with urllib.request.urlopen(url, timeout=30) as resp:
202
- raw = resp.read()
203
- data = json.loads(raw)
204
- if data.get("status") not in ("success", None):
205
- logger.warning(
206
- "_fetch_causal_matrix: backend returned status=%s for URL %s. Payload: %s",
207
- data.get("status"), url, data,
208
- )
209
- return None
210
- return data
211
- except Exception as exc:
212
- logger.warning("_fetch_causal_matrix failed for %s: %s", ticker, exc)
213
- return None
214
-
215
-
216
- def _safe_json(obj: Any) -> Any:
217
- """Recursively make numpy types JSON-serialisable."""
218
- try:
219
- import numpy as np
220
- if isinstance(obj, np.ndarray):
221
- return obj.tolist()
222
- if isinstance(obj, np.integer):
223
- return int(obj)
224
- if isinstance(obj, np.floating):
225
- return float(obj)
226
- except ImportError:
227
- pass
228
- if isinstance(obj, dict):
229
- return {k: _safe_json(v) for k, v in obj.items()}
230
- if isinstance(obj, (list, tuple)):
231
- return [_safe_json(v) for v in obj]
232
- return obj
233
-
234
-
235
- def _resolve_value(value: float, value_type: str, current: float) -> float:
236
- """Convert a user-supplied value + value_type to the absolute node value."""
237
- vt = value_type.strip().lower()
238
- if vt == "absolute":
239
- return value
240
- if vt == "multiplier":
241
- return current * value
242
- if vt == "percent_change":
243
- return current * (1.0 + value / 100.0)
244
- # default: treat as absolute
245
- return value
246
-
247
-
248
- # ─────────────────────────────────────────────────────────────────────────────
249
- # Pure-numpy inference helpers (no local causal training imports)
250
- # These functions work entirely from the payload returned by the backend API.
251
- # ──────────────────────────────────────────────────────────────��──────────────
252
-
253
- def _build_dag_from_payload(payload: dict):
254
- """
255
- Return a numpy bool DAG adjacency matrix and list of node names
256
- from the backend causal-matrix payload.
257
- """
258
- import numpy as np
259
- nodes = payload["nodes"]
260
- dag_adj = np.array(payload["dag_adj"], dtype=bool)
261
- adj_matrix = np.array(payload["adj_matrix"], dtype=float)
262
- return nodes, dag_adj, adj_matrix
263
-
264
-
265
- def _propagate_intervention(
266
- nodes: list,
267
- dag_adj,
268
- equations: dict,
269
- data_level,
270
- topological_order: list,
271
- treatment: str,
272
- abs_value: float,
273
- targets: list,
274
- horizon: int = 5,
275
- ):
276
- """
277
- Propagate a hard intervention (do(treatment=abs_value)) through the
278
- structural equations for `horizon` steps, returning ATE per target node.
279
- Uses only numpy — no local causal model imports.
280
- """
281
- import numpy as np
282
-
283
- node_to_idx = {n: i for i, n in enumerate(nodes)}
284
- n = len(nodes)
285
- T = data_level.shape[0]
286
-
287
- # Start from the last observed time step
288
- state = data_level[-1].copy().astype(float)
289
-
290
- # Fix the treatment node
291
- t_idx = node_to_idx[treatment]
292
- state[t_idx] = abs_value
293
-
294
- ate_per_target: Dict[str, float] = {}
295
- baseline = data_level[-1].copy().astype(float)
296
-
297
- for _ in range(horizon):
298
- new_state = state.copy()
299
- for node_name in topological_order:
300
- if node_name == treatment:
301
- continue
302
- eq = equations.get(node_name)
303
- if eq is None:
304
- continue
305
- parents = eq.get("parents", [])
306
- coefficients = eq.get("coefficients", {})
307
- intercept = float(eq.get("intercept", 0.0))
308
- if not parents:
309
- continue
310
- val = intercept
311
- for p in parents:
312
- p_idx = node_to_idx.get(p)
313
- if p_idx is not None:
314
- val += float(coefficients.get(p, 0.0)) * float(state[p_idx])
315
- n_idx = node_to_idx[node_name]
316
- new_state[n_idx] = val
317
- state = new_state
318
-
319
- for target in targets:
320
- t_i = node_to_idx.get(target)
321
- if t_i is not None:
322
- ate_per_target[target] = float(state[t_i] - baseline[t_i])
323
-
324
- return ate_per_target, state
325
-
326
-
327
- def _abduct_and_predict(
328
- nodes: list,
329
- dag_adj,
330
- equations: dict,
331
- data_level,
332
- topological_order: list,
333
- treatment: str,
334
- cf_value: float,
335
- target: str,
336
- observed_t: int,
337
- ):
338
- """
339
- Simple SCM abduction for counterfactual:
340
- 1. Abduct residuals from the observed time step.
341
- 2. Re-run structural equations with treatment fixed to cf_value.
342
- 3. Return factual_outcome, cf_outcome, ITE.
343
- """
344
- import numpy as np
345
-
346
- node_to_idx = {n: i for i, n in enumerate(nodes)}
347
- obs = data_level[observed_t].copy().astype(float)
348
-
349
- # Abduct residuals
350
- residuals: Dict[str, float] = {}
351
- for node_name in topological_order:
352
- eq = equations.get(node_name)
353
- if eq is None or not eq.get("parents"):
354
- residuals[node_name] = 0.0
355
- continue
356
- parents = eq.get("parents", [])
357
- coefficients = eq.get("coefficients", {})
358
- intercept = float(eq.get("intercept", 0.0))
359
- predicted = intercept
360
- for p in parents:
361
- p_idx = node_to_idx.get(p)
362
- if p_idx is not None:
363
- predicted += float(coefficients.get(p, 0.0)) * float(obs[node_to_idx[p]])
364
- residuals[node_name] = float(obs[node_to_idx[node_name]]) - predicted
365
-
366
- # Counterfactual: fix treatment, replay equations with abducted noise
367
- cf_state = obs.copy()
368
- cf_state[node_to_idx[treatment]] = cf_value
369
-
370
- for node_name in topological_order:
371
- if node_name == treatment:
372
- continue
373
- eq = equations.get(node_name)
374
- if eq is None or not eq.get("parents"):
375
- continue
376
- parents = eq.get("parents", [])
377
- coefficients = eq.get("coefficients", {})
378
- intercept = float(eq.get("intercept", 0.0))
379
- predicted = intercept
380
- for p in parents:
381
- p_idx = node_to_idx.get(p)
382
- if p_idx is not None:
383
- predicted += float(coefficients.get(p, 0.0)) * float(cf_state[p_idx])
384
- n_idx = node_to_idx[node_name]
385
- cf_state[n_idx] = predicted + residuals.get(node_name, 0.0)
386
-
387
- factual_outcome = float(obs[node_to_idx[target]])
388
- cf_outcome = float(cf_state[node_to_idx[target]])
389
- ite = cf_outcome - factual_outcome
390
- return factual_outcome, cf_outcome, ite
391
-
392
-
393
- # ── API: Causal inference (assert / intervene / counterfactual) ───────────
394
- #
395
- # Architecture:
396
- # 1. Fetch the VALIDATED causal matrix from noisy_boy_backend via HTTP.
397
- # The backend has already run CUTS+ learning + pywhyllm + DoWhy validation.
398
- # 2. Use the payload data (equations, adj, data_level) for inference
399
- # using pure numpy/pandas — no local causal training imports required.
400
- # 3. Optionally consult pywhyllm guidance from the backend payload.
401
- from huggingface_hub import spaces
402
-
403
- @spaces.GPU
404
- def run_inference(
405
- ticker: str = "RELIANCE",
406
- mode: str = "assert",
407
- treatment: str = "Revenue",
408
- outcome: Optional[str] = "NetIncome",
409
- target: Optional[str] = None,
410
- value: float = 1.1,
411
- cf_value: Optional[float] = None,
412
- value_type: str = "multiplier",
413
- horizon: int = 5,
414
- observed_t: int = -1,
415
- threshold: float = 0.5,
416
- use_pywhyllm: bool = False,
417
- return_assumption_report: bool = False,
418
- ) -> Dict[str, Any]:
419
- """
420
- Three-layer causal inference driven by the backend's validated causal matrix.
421
-
422
- Parameters
423
- ----------
424
- ticker : NSE ticker (backend must have a cached pipeline run for it)
425
- mode : "assert" | "intervene" | "counterfactual"
426
- treatment : source node name
427
- outcome : outcome node (assert / Layer-1 association)
428
- target : target node (counterfactual / Layer-3); if None, falls back to outcome
429
- value : intervention magnitude (Layer 2)
430
- cf_value : explicit counterfactual value (Layer 3); if None, 'value' + 'value_type' used
431
- value_type : "absolute" | "multiplier" | "percent_change"
432
- horizon : propagation horizon for intervention (Layer 2, steps)
433
- observed_t : time index for counterfactual abduction (Layer 3; -1 = last obs)
434
- threshold : adjacency threshold used when loading the graph
435
- use_pywhyllm : consult pywhyllm for structural assumptions before running DoWhy
436
- return_assumption_report : include the pywhyllm report dict in the response
437
-
438
- Returns
439
- -------
440
- JSON with ate, ci_lower, ci_upper, probability, ripple_effects,
441
- and (for counterfactual) factual_outcome, counterfactual_outcome, ite,
442
- shapley_contributions.
443
- """
444
- import numpy as np
445
- import pandas as pd
446
-
447
- try:
448
- # ── 0. Determine target node ──────────────────────────────────────────
449
- target_node = target if target else outcome
450
- if not target_node:
451
- return {"status": "error", "detail": "Either 'outcome' or 'target' must be provided."}
452
-
453
- # ── 1. Fetch validated causal matrix from backend ─────────────────────
454
- # This includes the adjacency matrix, fitted structural equations,
455
- # level-domain data, and optionally a pywhyllm assumption report.
456
- payload = _fetch_causal_matrix(
457
- ticker=ticker,
458
- treatment=treatment if use_pywhyllm else None,
459
- outcome=target_node if use_pywhyllm else None,
460
- include_pywhyllm=use_pywhyllm,
461
- threshold=threshold,
462
- )
463
-
464
- if payload is None:
465
- return {
466
- "status": "error",
467
- "detail": (
468
- f"Could not fetch causal matrix for {ticker} from backend. "
469
- "Ensure noisy_boy_backend is running and the pipeline has been run for this ticker."
470
- ),
471
- }
472
-
473
- if payload.get("status") == "not_found":
474
- return {
475
- "status": "error",
476
- "detail": payload.get("detail", f"No cached pipeline data for {ticker}."),
477
- }
478
-
479
- # ── 2. Unpack payload (no local causal training imports) ──────────────
480
- nodes, dag_adj, adj_matrix = _build_dag_from_payload(payload)
481
- node_to_idx = {n: i for i, n in enumerate(nodes)}
482
- data_level = np.array(payload["data_level"], dtype=float)
483
- equations_raw = payload.get("equations", {})
484
- topo_order = payload.get("topological_order", nodes)
485
-
486
- T = data_level.shape[0]
487
- df = pd.DataFrame(data_level, columns=nodes)
488
-
489
- if treatment not in node_to_idx:
490
- return {"status": "error", "detail": f"Unknown treatment node: {treatment}"}
491
- if target_node not in node_to_idx:
492
- return {"status": "error", "detail": f"Unknown outcome/target node: {target_node}"}
493
- if df.shape[0] < 5:
494
- return {
495
- "status": "error",
496
- "detail": f"Insufficient observations ({df.shape[0]}) to run inference.",
497
- }
498
-
499
- # ── 3. pywhyllm structural guidance (from backend payload) ────────────
500
- pywhyllm_report: Optional[dict] = payload.get("pywhyllm_report")
501
- adjustment_sets: List[List[str]] = []
502
-
503
- if use_pywhyllm and pywhyllm_report and pywhyllm_report.get("available"):
504
- raw_backdoor = pywhyllm_report.get("suggested_backdoor_sets") or []
505
- valid_nodes = set(nodes) - {treatment, target_node}
506
- for suggested_set in raw_backdoor:
507
- clean = [n for n in suggested_set if n in valid_nodes]
508
- if clean and clean not in adjustment_sets:
509
- adjustment_sets.append(clean)
510
-
511
- confounders = [
512
- n for n in (pywhyllm_report.get("suggested_confounders") or [])
513
- if n in valid_nodes
514
- ]
515
- if confounders and confounders not in adjustment_sets:
516
- adjustment_sets.append(confounders)
517
-
518
- result: Dict[str, Any] = {}
519
-
520
- # ═══════════════════════════════════════════════════════════════════════
521
- # LAYER 1 — Association: "What does Y look like given X?"
522
- # Uses DoWhy with the backend-provided DAG, falling back to OLS.
523
- # ═══════════════════════════════════════════════════════════════════════
524
- if mode == "assert":
525
- try:
526
- from dowhy import CausalModel
527
-
528
- # Build DOT graph string from dag_adj
529
- edges = []
530
- for si, src in enumerate(nodes):
531
- for di, dst in enumerate(nodes):
532
- if dag_adj[si, di]:
533
- edges.append(f"{src} -> {dst}")
534
- graph_dot = "digraph{" + "; ".join(edges) + "}"
535
-
536
- dowhy_model = CausalModel(
537
- data=df,
538
- treatment=treatment,
539
- outcome=target_node,
540
- graph=graph_dot,
541
- )
542
- identified_estimand = dowhy_model.identify_effect(
543
- proceed_when_unidentifiable=True
544
- )
545
- estimate = dowhy_model.estimate_effect(
546
- identified_estimand,
547
- method_name="backdoor.linear_regression",
548
- )
549
- ate = float(estimate.value)
550
-
551
- # Confidence interval from OLS residuals
552
- se: float = 0.0
553
- try:
554
- import numpy.linalg as nla
555
- X = df[[c for c in df.columns if c != target_node]].values
556
- y = df[target_node].values
557
- XtX_inv = nla.pinv(X.T @ X)
558
- resid = y - X @ nla.lstsq(X, y, rcond=None)[0]
559
- sigma2 = float(np.sum(resid ** 2) / max(1, len(y) - X.shape[1]))
560
- t_idx_local = list(df.columns).index(treatment)
561
- se = float(np.sqrt(max(0.0, sigma2 * XtX_inv[t_idx_local, t_idx_local])))
562
- except Exception:
563
- se = abs(ate) * 0.15 # graceful fallback
564
-
565
- ci_lower = ate - 1.96 * se
566
- ci_upper = ate + 1.96 * se
567
- prob = min(1.0, abs(ate) / (abs(ate) + se + 1e-9))
568
-
569
- # Ripple effects: direct downstream neighbours of treatment
570
- ripple_effects = []
571
- t_idx_g = node_to_idx[treatment]
572
- for j, node in enumerate(nodes):
573
- if node == treatment or node == target_node:
574
- continue
575
- if dag_adj[t_idx_g, j]:
576
- edge_score = float(adj_matrix[t_idx_g, j])
577
- ripple_effects.append({
578
- "ticker": node,
579
- "direction": 1 if ate > 0 else -1,
580
- "magnitude": round(edge_score * abs(ate), 4),
581
- })
582
-
583
- result = {
584
- "ate": ate,
585
- "ci_lower": ci_lower,
586
- "ci_upper": ci_upper,
587
- "probability": prob,
588
- "strategy": "backdoor.linear_regression",
589
- "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
590
- "ripple_effects": ripple_effects,
591
- }
592
-
593
- except Exception as dowhy_exc:
594
- # DoWhy not installed or identification failed — fall back to OLS
595
- logger.warning("DoWhy association failed (%s), falling back to OLS", dowhy_exc)
596
- t_idx_g = node_to_idx[treatment]
597
- out_idx = node_to_idx[target_node]
598
-
599
- # Simple OLS: regress target on treatment
600
- X = df[[treatment]].values
601
- y = df[target_node].values
602
- import numpy.linalg as nla
603
- coef = nla.lstsq(np.c_[np.ones(len(X)), X], y, rcond=None)[0]
604
- ate = float(coef[1])
605
- se = abs(ate) * 0.15
606
- ci_lower = ate - 1.96 * se
607
- ci_upper = ate + 1.96 * se
608
-
609
- ripple_effects = []
610
- for j, node in enumerate(nodes):
611
- if node == treatment or node == target_node:
612
- continue
613
- if dag_adj[t_idx_g, j]:
614
- ripple_effects.append({
615
- "ticker": node,
616
- "direction": 1 if ate > 0 else -1,
617
- "magnitude": round(float(adj_matrix[t_idx_g, j]) * abs(ate), 4),
618
- })
619
-
620
- result = {
621
- "ate": ate,
622
- "ci_lower": ci_lower,
623
- "ci_upper": ci_upper,
624
- "probability": min(1.0, abs(ate) / (abs(ate) + se + 1e-9)),
625
- "strategy": "ols_fallback",
626
- "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
627
- "ripple_effects": ripple_effects,
628
- }
629
-
630
- # ═══════════════════════════════════════════════════════════════════════
631
- # LAYER 2 — Intervention: "What will happen to Y if we do X=value?"
632
- # Propagates through structural equations from the backend payload.
633
- # ═══════════════════════════════════════════════════════════════════════
634
- elif mode == "intervene":
635
- current_val = float(data_level[-1, node_to_idx[treatment]])
636
- abs_value = _resolve_value(value, value_type, current_val)
637
-
638
- # Try DoWhy for ATE estimation first
639
- ate = 0.0
640
- method_used = "scm_propagation"
641
- try:
642
- from dowhy import CausalModel
643
-
644
- edges = []
645
- for si, src in enumerate(nodes):
646
- for di, dst in enumerate(nodes):
647
- if dag_adj[si, di]:
648
- edges.append(f"{src} -> {dst}")
649
- graph_dot = "digraph{" + "; ".join(edges) + "}"
650
-
651
- dowhy_model = CausalModel(
652
- data=df,
653
- treatment=treatment,
654
- outcome=target_node,
655
- graph=graph_dot,
656
- )
657
- identified_estimand = dowhy_model.identify_effect(
658
- proceed_when_unidentifiable=True
659
- )
660
- estimate = dowhy_model.estimate_effect(
661
- identified_estimand,
662
- method_name="backdoor.linear_regression",
663
- )
664
- ate_unit = float(estimate.value)
665
- delta = abs_value - current_val
666
- ate = ate_unit * delta
667
- method_used = "backdoor.linear_regression"
668
- except Exception as dowhy_exc:
669
- logger.warning("DoWhy intervention failed (%s), using SCM propagation", dowhy_exc)
670
-
671
- # SCM propagation for ripple effects (pure numpy, no training imports)
672
- ate_per_target, final_state = _propagate_intervention(
673
- nodes=nodes,
674
- dag_adj=dag_adj,
675
- equations=equations_raw,
676
- data_level=data_level,
677
- topological_order=topo_order,
678
- treatment=treatment,
679
- abs_value=abs_value,
680
- targets=[target_node] + [n for n in nodes if n != treatment],
681
- horizon=horizon,
682
- )
683
-
684
- if method_used == "scm_propagation" and target_node in ate_per_target:
685
- ate = float(ate_per_target[target_node])
686
-
687
- se = abs(ate) * 0.12
688
- ci_lower = ate - 1.96 * se
689
- ci_upper = ate + 1.96 * se
690
-
691
- ripple_effects = []
692
- for node, delta_val in ate_per_target.items():
693
- if node == treatment:
694
- continue
695
- ripple_effects.append({
696
- "ticker": node,
697
- "direction": 1 if float(delta_val) > 0 else -1,
698
- "magnitude": round(abs(float(delta_val)), 4),
699
- })
700
-
701
- result = {
702
- "ate": ate,
703
- "ci_lower": ci_lower,
704
- "ci_upper": ci_upper,
705
- "probability": min(1.0, abs(ate) / (abs(ate) + abs(ci_upper - ci_lower) / 2 + 1e-9)),
706
- "strategy": method_used,
707
- "intervention_value": abs_value,
708
- "value_type": value_type,
709
- "horizon": horizon,
710
- "ripple_effects": ripple_effects,
711
- "adjustment_set": adjustment_sets[0] if adjustment_sets else [],
712
- }
713
-
714
- # ═══════════════════════════════════════════════════════════════════════
715
- # LAYER 3 — Counterfactual: "What if X had been different in the past?"
716
- # Uses SCM abduction via pure numpy structural equations.
717
- # ═══════════════════════════════════════════════════════════════════════
718
- elif mode in ("counterfactual", "counter"):
719
- # Resolve observed timestep
720
- t = observed_t if observed_t >= 0 else (T + observed_t)
721
- t = max(0, min(T - 1, t))
722
-
723
- # Resolve counterfactual value
724
- current_val = float(data_level[t, node_to_idx[treatment]])
725
- if cf_value is not None:
726
- abs_cf_value = float(cf_value)
727
- else:
728
- abs_cf_value = _resolve_value(value, value_type, current_val)
729
-
730
- # Try DoWhy GCM first
731
- gcm_used = False
732
- factual_outcome = 0.0
733
- cf_outcome_val = 0.0
734
- ite = 0.0
735
-
736
- try:
737
- import dowhy.gcm as gcm_module
738
- import networkx as nx
739
-
740
- causal_graph = nx.DiGraph()
741
- for si, src in enumerate(nodes):
742
- for di, dst in enumerate(nodes):
743
- if dag_adj[si, di]:
744
- causal_graph.add_edge(src, dst)
745
- for node in nodes:
746
- if node not in causal_graph.nodes:
747
- causal_graph.add_node(node)
748
-
749
- gcm_model = gcm_module.InvertibleStructuralCausalModel(causal_graph)
750
- gcm_module.auto.assign_mechanisms(gcm_model, df)
751
- gcm_module.fit(gcm_model, df)
752
-
753
- observed_data = df.iloc[[t]]
754
- cf_val_fixed = abs_cf_value
755
- cf_samples = gcm_module.counterfactual_samples(
756
- gcm_model,
757
- {treatment: lambda x, v=cf_val_fixed: np.full(x.shape, v)},
758
- observed_data=observed_data,
759
- num_samples_to_draw=1,
760
- )
761
-
762
- factual_outcome = float(observed_data[target_node].iloc[0])
763
- cf_outcome_val = float(cf_samples[target_node].iloc[0])
764
- ite = cf_outcome_val - factual_outcome
765
- gcm_used = True
766
-
767
- except Exception as gcm_exc:
768
- logger.warning("DoWhy GCM counterfactual failed (%s), using SCM abduction", gcm_exc)
769
-
770
- if not gcm_used:
771
- factual_outcome, cf_outcome_val, ite = _abduct_and_predict(
772
- nodes=nodes,
773
- dag_adj=dag_adj,
774
- equations=equations_raw,
775
- data_level=data_level,
776
- topological_order=topo_order,
777
- treatment=treatment,
778
- cf_value=abs_cf_value,
779
- target=target_node,
780
- observed_t=t,
781
- )
782
-
783
- # Shapley: single-treatment — just use the ITE directly
784
- shapley = {treatment: ite}
785
-
786
- # SE from residual_std of the target equation (from backend payload)
787
- target_eq_data = equations_raw.get(target_node, {})
788
- se = float(target_eq_data.get("residual_std", abs(ite) * 0.15))
789
- ci_lower = ite - 1.96 * se
790
- ci_upper = ite + 1.96 * se
791
-
792
- result = {
793
- "ate": ite,
794
- "ite": ite,
795
- "factual_outcome": factual_outcome,
796
- "counterfactual_outcome": cf_outcome_val,
797
- "ci_lower": ci_lower,
798
- "ci_upper": ci_upper,
799
- "probability": min(1.0, abs(ite) / (abs(ite) + se + 1e-9)),
800
- "strategy": "dowhy_gcm" if gcm_used else "scm_abduction",
801
- "counterfactual_value": abs_cf_value,
802
- "value_type": value_type,
803
- "observed_t": t,
804
- "shapley_contributions": shapley,
805
- "ripple_effects": [],
806
- }
807
-
808
- else:
809
- return {
810
- "status": "error",
811
- "detail": f"Unknown mode '{mode}'. Must be one of: assert, intervene, counterfactual.",
812
- }
813
-
814
- # ── Attach pywhyllm assumption report if requested ────────────────────
815
- if return_assumption_report and pywhyllm_report:
816
- result["pywhyllm_report"] = pywhyllm_report
817
-
818
- return _safe_json({"status": "ok", "ticker": ticker.upper(), "mode": mode, **result})
819
-
820
- except Exception as exc:
821
- logger.exception("run_inference failed")
822
- return {"status": "error", "detail": str(exc)}
823
-
824
-
825
- # ─────────────────────────────────────────────────────────────────────────────
826
- # Gradio UI & Entry point
827
- # ─────────────────────────────────────────────────────────────────────────────
828
-
829
- with gr.Blocks(title="Iroha Causal Terminal") as demo:
830
- gr.Markdown("# Iroha Causal Terminal")
831
- gr.Markdown("Iroha Financial Intelligence — real-time causal probability matrix, HHKD decomposition, DoFlow inference and sector hierarchy over NIFTY50.")
832
-
833
- with gr.Row():
834
- ticker = gr.Textbox(label="Ticker", value="RELIANCE")
835
- mode = gr.Dropdown(choices=["assert", "intervene", "counterfactual"], label="Mode", value="assert")
836
- treatment = gr.Textbox(label="Treatment", value="Revenue")
837
- outcome = gr.Textbox(label="Outcome", value="NetIncome")
838
- target = gr.Textbox(label="Target", value="")
839
-
840
- with gr.Row():
841
- value = gr.Number(label="Value", value=1.1)
842
- cf_value = gr.Number(label="CF Value")
843
- value_type = gr.Dropdown(choices=["absolute", "multiplier", "percent_change"], label="Value Type", value="multiplier")
844
- horizon = gr.Number(label="Horizon", value=5, precision=0)
845
- observed_t = gr.Number(label="Observed T", value=-1, precision=0)
846
- threshold = gr.Number(label="Threshold", value=0.5)
847
-
848
- with gr.Row():
849
- use_pywhyllm = gr.Checkbox(label="Use PyWhyLLM", value=False)
850
- return_assumption_report = gr.Checkbox(label="Return Assumption Report", value=False)
851
-
852
- btn = gr.Button("Run Inference")
853
- out = gr.JSON(label="Result")
854
-
855
- btn.click(
856
- fn=run_inference,
857
- inputs=[
858
- ticker, mode, treatment, outcome, target, value, cf_value, value_type,
859
- horizon, observed_t, threshold, use_pywhyllm, return_assumption_report
860
- ],
861
- outputs=out,
862
- api_name="run_inference"
863
- )
864
-
865
- if __name__ == "__main__":
866
- port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", "7860")))
867
- host = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0")
868
-
869
- logger.info(f"Starting Iroha Causal Terminal on {host}:{port}")
870
-
871
- demo.launch(
872
- server_name=host,
873
- server_port=port,
874
- show_error=True,
875
- ssr_mode="cdn"
876
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server.py CHANGED
@@ -883,4 +883,5 @@ if __name__ == "__main__":
883
  server_name=host,
884
  server_port=port,
885
  show_error=True,
 
886
  )
 
883
  server_name=host,
884
  server_port=port,
885
  show_error=True,
886
+ ssr_mode="cdn"
887
  )