ninarg commited on
Commit
f5c5771
Β·
1 Parent(s): d55fca5

Initial: Streamlit + streamlit-agraph accounting-network explorer (v5.9.0)

Browse files
Files changed (4) hide show
  1. .gitignore +4 -0
  2. README.md +56 -9
  3. app.py +446 -0
  4. requirements.txt +5 -0
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .streamlit/secrets.toml
4
+ .venv/
README.md CHANGED
@@ -1,13 +1,60 @@
1
  ---
2
- title: Accounting Network Explorer
3
- emoji: 🏒
4
- colorFrom: gray
5
- colorTo: gray
6
- sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: VynFi Accounting Network Explorer
3
+ emoji: πŸ”—
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: streamlit
7
+ sdk_version: 1.39.0
 
8
  app_file: app.py
9
+ pinned: true
10
+ license: apache-2.0
11
+ short_description: Interactive ISO 21378 account-class flow graph (v5.9.0)
12
+ tags:
13
+ - vynfi
14
+ - accounting
15
+ - graph
16
+ - iso-21378
17
+ - synthetic-data
18
+ - financial-network
19
  ---
20
 
21
+ # πŸ”— VynFi Accounting Network Explorer
22
+
23
+ Interactive view of the v5.9.0 Method-A accounting network published in
24
+ [`VynFi/vynfi-journal-entries-1m`](https://huggingface.co/datasets/VynFi/vynfi-journal-entries-1m),
25
+ aggregated to **ISO 21378 Level-2** account classes (~30 nodes).
26
+
27
+ ## What you can do
28
+
29
+ * **Filter** the underlying 61 656 line-level edges by business process
30
+ (P2P / O2C / R2R / H2R / A2R), `is_fraud`, `is_anomaly`,
31
+ minimum edge amount, and top-N.
32
+ * **Inspect** any class node to see total flow, fraud %, and the top
33
+ in/out class pairs.
34
+ * **Drill in** to the Level-3 sub-class breakdown
35
+ (`A.A.A β€” Operating Cash`, `A.A.B β€” Petty Cash`, …).
36
+ * **Toggle** force-directed vs hierarchical layout.
37
+
38
+ ## Method A vs Cartesian
39
+
40
+ In v5.9.0 the JE-network defaults to *Method A* from Ivertowski 2024:
41
+ exactly **one edge per 2-line journal entry**, confidence = 1.0.
42
+ This avoids the Cartesian explosion (225 M edges on 1 M JEs) that the
43
+ legacy `cartesian` method produces, and gives a clean topology for
44
+ graph-ML training.
45
+
46
+ ## Tech
47
+
48
+ Streamlit + `streamlit-agraph` (vis-network) Β· pandas/pyarrow Β·
49
+ loads parquet directly from the HF dataset on cold-start, then
50
+ caches in-memory.
51
+
52
+ ## Source
53
+
54
+ * App code: [github.com/mivertowski/SyntheticData/tree/main/spaces/accounting-network-explorer](https://github.com/mivertowski/SyntheticData/tree/main/spaces/accounting-network-explorer)
55
+ * Generation engine: [github.com/mivertowski/SyntheticData](https://github.com/mivertowski/SyntheticData)
56
+ * Companion paper: [SSRN abstract 6538639](https://ssrn.com/abstract=6538639)
57
+
58
+ ## License
59
+
60
+ Apache-2.0.
app.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """VynFi Accounting Network Explorer.
2
+
3
+ Interactive ISO 21378 Level-2 account-class network from
4
+ `VynFi/vynfi-journal-entries-1m`. One node per account class,
5
+ one edge per (from_class, to_class) pair aggregated from the
6
+ v5.9.0 Method-A `je_network.parquet` (2-line JEs only,
7
+ confidence = 1.0).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Tuple
13
+
14
+ import pandas as pd
15
+ import streamlit as st
16
+ from huggingface_hub import snapshot_download
17
+ from streamlit_agraph import Config, Edge, Node, agraph
18
+
19
+ DATASET_REPO = "VynFi/vynfi-journal-entries-1m"
20
+
21
+ ACCOUNT_TYPE_COLORS = {
22
+ "asset": "#2563eb", # blue
23
+ "liability": "#ea580c", # orange
24
+ "equity": "#16a34a", # green
25
+ "revenue": "#9333ea", # purple
26
+ "expense": "#dc2626", # red
27
+ "other": "#6b7280", # grey
28
+ }
29
+
30
+ st.set_page_config(
31
+ page_title="VynFi Accounting Network Explorer",
32
+ page_icon="πŸ”—",
33
+ layout="wide",
34
+ initial_sidebar_state="expanded",
35
+ )
36
+
37
+
38
+ # ─── Data loading ────────────────────────────────────────────────────────────
39
+
40
+
41
+ @st.cache_resource(show_spinner="Downloading je_network + chart_of_accounts from HF Hub…")
42
+ def load_data() -> Tuple[pd.DataFrame, pd.DataFrame]:
43
+ base = snapshot_download(
44
+ repo_id=DATASET_REPO,
45
+ repo_type="dataset",
46
+ allow_patterns=["je_network.parquet", "chart_of_accounts.parquet"],
47
+ )
48
+ edges = pd.read_parquet(f"{base}/je_network.parquet")
49
+ coa = pd.read_parquet(f"{base}/chart_of_accounts.parquet")
50
+
51
+ # Normalise dtypes
52
+ edges["from_account"] = edges["from_account"].astype(str)
53
+ edges["to_account"] = edges["to_account"].astype(str)
54
+ coa["account_number"] = coa["account_number"].astype(str)
55
+ coa["account_type"] = coa["account_type"].astype(str).str.lower()
56
+
57
+ # 4 account numbers in the published COA (1510, 1600, 4900, 7100) appear
58
+ # in two rows with conflicting class mappings β€” keep the first deterministically
59
+ # so the join doesn't inflate the edge count.
60
+ coa = coa.drop_duplicates(subset=["account_number"], keep="first").reset_index(drop=True)
61
+
62
+ return edges, coa
63
+
64
+
65
+ # ─── Aggregation ─────────────────────────────────────────────────────────────
66
+
67
+
68
+ def aggregate_to_class(edges: pd.DataFrame, coa: pd.DataFrame):
69
+ """Join edges with COA on gl_account and aggregate by (from_class, to_class)."""
70
+ coa_slim = coa[
71
+ ["account_number", "account_class", "account_class_name", "account_type"]
72
+ ].copy()
73
+
74
+ e = (
75
+ edges.merge(
76
+ coa_slim.rename(
77
+ columns={
78
+ "account_number": "from_account",
79
+ "account_class": "from_class",
80
+ "account_class_name": "from_class_name",
81
+ "account_type": "from_type",
82
+ }
83
+ ),
84
+ on="from_account",
85
+ how="left",
86
+ )
87
+ .merge(
88
+ coa_slim.rename(
89
+ columns={
90
+ "account_number": "to_account",
91
+ "account_class": "to_class",
92
+ "account_class_name": "to_class_name",
93
+ "account_type": "to_type",
94
+ }
95
+ ),
96
+ on="to_account",
97
+ how="left",
98
+ )
99
+ .dropna(subset=["from_class", "to_class"])
100
+ )
101
+
102
+ class_edges = (
103
+ e.groupby(["from_class", "to_class"], as_index=False)
104
+ .agg(
105
+ total_amount=("amount", "sum"),
106
+ edge_count=("edge_id", "count"),
107
+ fraud_count=("is_fraud", "sum"),
108
+ anomaly_count=("is_anomaly", "sum"),
109
+ )
110
+ )
111
+
112
+ out = (
113
+ e.groupby("from_class", as_index=False)
114
+ .agg(out_amount=("amount", "sum"), out_count=("edge_id", "count"))
115
+ .rename(columns={"from_class": "account_class"})
116
+ )
117
+ inn = (
118
+ e.groupby("to_class", as_index=False)
119
+ .agg(in_amount=("amount", "sum"), in_count=("edge_id", "count"))
120
+ .rename(columns={"to_class": "account_class"})
121
+ )
122
+ nodes = pd.merge(out, inn, on="account_class", how="outer").fillna(0)
123
+
124
+ meta = (
125
+ coa.groupby("account_class", as_index=False)
126
+ .agg(
127
+ account_class_name=("account_class_name", "first"),
128
+ account_type=("account_type", "first"),
129
+ )
130
+ )
131
+ nodes = nodes.merge(meta, on="account_class", how="left")
132
+ nodes["account_class_name"] = nodes["account_class_name"].fillna(nodes["account_class"])
133
+ nodes["account_type"] = nodes["account_type"].fillna("other")
134
+ nodes["total_flow"] = nodes["in_amount"] + nodes["out_amount"]
135
+ nodes["total_count"] = nodes["in_count"] + nodes["out_count"]
136
+
137
+ return nodes, class_edges
138
+
139
+
140
+ # ─── Formatters ───────────────��──────────────────────────────────────────────
141
+
142
+
143
+ def fmt_money(x: float) -> str:
144
+ sign = "-" if x < 0 else ""
145
+ x = abs(float(x))
146
+ if x >= 1e12:
147
+ return f"{sign}${x / 1e12:.2f}T"
148
+ if x >= 1e9:
149
+ return f"{sign}${x / 1e9:.2f}B"
150
+ if x >= 1e6:
151
+ return f"{sign}${x / 1e6:.2f}M"
152
+ if x >= 1e3:
153
+ return f"{sign}${x / 1e3:.1f}K"
154
+ return f"{sign}${x:.0f}"
155
+
156
+
157
+ def node_size(amount: float, max_amount: float) -> int:
158
+ if amount <= 0 or max_amount <= 0:
159
+ return 18
160
+ ratio = math.log10(amount + 1.0) / max(math.log10(max_amount + 1.0), 1.0)
161
+ return int(18 + ratio * 42)
162
+
163
+
164
+ def edge_width(amount: float, max_amount: float) -> int:
165
+ if amount <= 0 or max_amount <= 0:
166
+ return 1
167
+ ratio = math.log10(amount + 1.0) / max(math.log10(max_amount + 1.0), 1.0)
168
+ return max(1, int(ratio * 8))
169
+
170
+
171
+ # ─── Sidebar β€” filters ───────────────────────────────────────────────────────
172
+
173
+
174
+ edges_raw, coa_raw = load_data()
175
+
176
+ st.title("πŸ”— VynFi Accounting Network Explorer")
177
+ st.caption(
178
+ "ISO 21378 Level-2 account-class flows from "
179
+ "[`VynFi/vynfi-journal-entries-1m`](https://huggingface.co/datasets/VynFi/vynfi-journal-entries-1m) Β· "
180
+ "Method-A edge list (one edge per 2-line JE) Β· v5.9.0"
181
+ )
182
+
183
+ with st.sidebar:
184
+ st.header("Filters")
185
+
186
+ processes = sorted(edges_raw["business_process"].dropna().unique().tolist())
187
+ selected_processes = st.multiselect(
188
+ "Business process",
189
+ processes,
190
+ default=processes,
191
+ help="P2P = procure-to-pay Β· O2C = order-to-cash Β· R2R = record-to-report Β· "
192
+ "H2R = hire-to-retire Β· A2R = adjust-to-report",
193
+ )
194
+
195
+ col_a, col_b = st.columns(2)
196
+ with col_a:
197
+ fraud_only = st.checkbox("Fraud only", value=False)
198
+ with col_b:
199
+ anomaly_only = st.checkbox("Anomaly only", value=False)
200
+
201
+ st.divider()
202
+
203
+ min_amount_log = st.slider(
204
+ "Min edge total (10ⁿ)",
205
+ min_value=0,
206
+ max_value=12,
207
+ value=0,
208
+ step=1,
209
+ help="Hide class-pairs whose summed flow is below 10ⁿ.",
210
+ )
211
+ top_n = st.slider("Top N edges", min_value=20, max_value=400, value=120, step=20)
212
+
213
+ st.divider()
214
+
215
+ layout_mode = st.radio(
216
+ "Layout",
217
+ ["force-directed", "hierarchical"],
218
+ horizontal=True,
219
+ )
220
+
221
+ st.divider()
222
+ st.caption(
223
+ f"**Source rows:** {len(edges_raw):,} edges Β· {len(coa_raw):,} accounts \n"
224
+ f"_v5.9.0 Β· ChaCha8 seed `20260509`_"
225
+ )
226
+
227
+
228
+ # ─── Filter the raw edges ────────────────────────────────────────────────────
229
+
230
+
231
+ filt = edges_raw[edges_raw["business_process"].isin(selected_processes)]
232
+ if fraud_only:
233
+ filt = filt[filt["is_fraud"]]
234
+ if anomaly_only:
235
+ filt = filt[filt["is_anomaly"]]
236
+
237
+ if filt.empty:
238
+ st.warning("No edges match the current filter combination β€” relax the filters.")
239
+ st.stop()
240
+
241
+ nodes_df, class_edges_df = aggregate_to_class(filt, coa_raw)
242
+
243
+ class_edges_df = class_edges_df[class_edges_df["total_amount"] >= 10**min_amount_log]
244
+ class_edges_df = class_edges_df.nlargest(top_n, "total_amount")
245
+
246
+ keep_classes = set(class_edges_df["from_class"]) | set(class_edges_df["to_class"])
247
+ nodes_df = nodes_df[nodes_df["account_class"].isin(keep_classes)].copy()
248
+
249
+ if class_edges_df.empty or nodes_df.empty:
250
+ st.warning("Filters produced an empty graph β€” relax the min-amount cutoff.")
251
+ st.stop()
252
+
253
+
254
+ # ─── Build agraph nodes/edges ────────────────────────────────────────────────
255
+
256
+
257
+ max_node = nodes_df["total_flow"].max()
258
+ max_edge = class_edges_df["total_amount"].max()
259
+
260
+ agraph_nodes = []
261
+ for _, n in nodes_df.iterrows():
262
+ color = ACCOUNT_TYPE_COLORS.get(str(n["account_type"]).lower(), ACCOUNT_TYPE_COLORS["other"])
263
+ label = f"{n['account_class']}\n{str(n['account_class_name'])[:24]}"
264
+ title = (
265
+ f"Class {n['account_class']} ({n['account_type']})\n"
266
+ f"{n['account_class_name']}\n"
267
+ f"Total flow: {fmt_money(n['total_flow'])}\n"
268
+ f"Edges: {int(n['total_count'])}\n"
269
+ f"In: {fmt_money(n['in_amount'])} ({int(n['in_count'])})\n"
270
+ f"Out: {fmt_money(n['out_amount'])} ({int(n['out_count'])})"
271
+ )
272
+ agraph_nodes.append(
273
+ Node(
274
+ id=str(n["account_class"]),
275
+ label=label,
276
+ title=title,
277
+ size=node_size(n["total_flow"], max_node),
278
+ color=color,
279
+ font={"color": "#ffffff", "size": 11, "face": "monospace"},
280
+ shape="dot",
281
+ )
282
+ )
283
+
284
+ agraph_edges = []
285
+ for _, e in class_edges_df.iterrows():
286
+ fraud_pct = (e["fraud_count"] / e["edge_count"] * 100) if e["edge_count"] else 0.0
287
+ title = (
288
+ f"{e['from_class']} β†’ {e['to_class']}\n"
289
+ f"Total: {fmt_money(e['total_amount'])}\n"
290
+ f"Edges: {int(e['edge_count'])}\n"
291
+ f"Fraud: {int(e['fraud_count'])} ({fraud_pct:.1f}%)\n"
292
+ f"Anomaly: {int(e['anomaly_count'])}"
293
+ )
294
+ color = "#dc2626" if e["fraud_count"] > 0 else "#94a3b8"
295
+ agraph_edges.append(
296
+ Edge(
297
+ source=str(e["from_class"]),
298
+ target=str(e["to_class"]),
299
+ title=title,
300
+ color=color,
301
+ type="CURVE_SMOOTH",
302
+ width=edge_width(e["total_amount"], max_edge),
303
+ )
304
+ )
305
+
306
+
307
+ # ─── Layout ──────────────────────────────────────────────────────────────────
308
+
309
+
310
+ config = Config(
311
+ width=900,
312
+ height=650,
313
+ directed=True,
314
+ physics=(layout_mode == "force-directed"),
315
+ hierarchical=(layout_mode == "hierarchical"),
316
+ )
317
+
318
+ graph_col, side_col = st.columns([3, 1])
319
+ with graph_col:
320
+ selected = agraph(nodes=agraph_nodes, edges=agraph_edges, config=config)
321
+
322
+ with side_col:
323
+ st.subheader("Summary")
324
+ sm1, sm2 = st.columns(2)
325
+ sm1.metric("Classes", len(nodes_df))
326
+ sm2.metric("Edges", len(class_edges_df))
327
+ st.metric("Total flow", fmt_money(class_edges_df["total_amount"].sum()))
328
+ st.metric("Fraud edges", int(class_edges_df["fraud_count"].sum()))
329
+ st.metric("Anomaly edges", int(class_edges_df["anomaly_count"].sum()))
330
+
331
+ st.divider()
332
+
333
+ if selected:
334
+ n_match = nodes_df[nodes_df["account_class"] == selected]
335
+ if not n_match.empty:
336
+ n = n_match.iloc[0]
337
+ color = ACCOUNT_TYPE_COLORS.get(
338
+ str(n["account_type"]).lower(), ACCOUNT_TYPE_COLORS["other"]
339
+ )
340
+ st.markdown(
341
+ f"<h4 style='margin:0'>"
342
+ f"<span style='color:{color}'>●</span> "
343
+ f"<code>{n['account_class']}</code></h4>",
344
+ unsafe_allow_html=True,
345
+ )
346
+ st.markdown(f"**{n['account_class_name']}** \n_{n['account_type']}_")
347
+ st.markdown(
348
+ f"- Total flow: **{fmt_money(n['total_flow'])}** \n"
349
+ f"- Out: {fmt_money(n['out_amount'])} ({int(n['out_count'])}) \n"
350
+ f"- In: {fmt_money(n['in_amount'])} ({int(n['in_count'])})"
351
+ )
352
+
353
+ outs = class_edges_df[class_edges_df["from_class"] == selected].nlargest(
354
+ 5, "total_amount"
355
+ )
356
+ if not outs.empty:
357
+ st.markdown("**Top outgoing**")
358
+ for _, oe in outs.iterrows():
359
+ st.markdown(
360
+ f"β†’ `{oe['to_class']}` Β· {fmt_money(oe['total_amount'])} "
361
+ f"({int(oe['edge_count'])} edges)"
362
+ )
363
+
364
+ ins = class_edges_df[class_edges_df["to_class"] == selected].nlargest(
365
+ 5, "total_amount"
366
+ )
367
+ if not ins.empty:
368
+ st.markdown("**Top incoming**")
369
+ for _, ie in ins.iterrows():
370
+ st.markdown(
371
+ f"← `{ie['from_class']}` Β· {fmt_money(ie['total_amount'])} "
372
+ f"({int(ie['edge_count'])} edges)"
373
+ )
374
+
375
+ subs = (
376
+ coa_raw[coa_raw["account_class"] == selected]
377
+ .groupby(["account_sub_class", "account_sub_class_name"], as_index=False)
378
+ .size()
379
+ )
380
+ if not subs.empty:
381
+ with st.expander(f"Level-3 sub-classes ({len(subs)})"):
382
+ for _, s in subs.iterrows():
383
+ st.markdown(
384
+ f"`{s['account_sub_class']}` β€” {s['account_sub_class_name']}"
385
+ )
386
+ else:
387
+ st.info("Selected class is not currently visible β€” relax filters.")
388
+ else:
389
+ st.info("Click a node in the graph to drill in.")
390
+
391
+ st.divider()
392
+
393
+ with st.expander("Top edges (table view)", expanded=False):
394
+ table = class_edges_df.assign(
395
+ total=class_edges_df["total_amount"].apply(fmt_money),
396
+ fraud_pct=(class_edges_df["fraud_count"] / class_edges_df["edge_count"] * 100).round(2),
397
+ )[
398
+ [
399
+ "from_class",
400
+ "to_class",
401
+ "total",
402
+ "edge_count",
403
+ "fraud_count",
404
+ "anomaly_count",
405
+ "fraud_pct",
406
+ ]
407
+ ].rename(
408
+ columns={
409
+ "from_class": "From",
410
+ "to_class": "To",
411
+ "total": "Total $",
412
+ "edge_count": "Edges",
413
+ "fraud_count": "Fraud",
414
+ "anomaly_count": "Anomaly",
415
+ "fraud_pct": "Fraud %",
416
+ }
417
+ )
418
+ st.dataframe(table, use_container_width=True, hide_index=True)
419
+
420
+ with st.expander("About this Space", expanded=False):
421
+ st.markdown(
422
+ """
423
+ **What this is.** An interactive view of the v5.9.0 Method-A
424
+ accounting network published in
425
+ [`VynFi/vynfi-journal-entries-1m`](https://huggingface.co/datasets/VynFi/vynfi-journal-entries-1m).
426
+ The 61 656 line-level edges are aggregated to ISO 21378 Level-2
427
+ account classes (~30 nodes), so you can see the macro money-flow
428
+ structure at a glance.
429
+
430
+ **Method-A.** In v5.9.0 the JE network defaults to "Method A"
431
+ from Ivertowski 2024: exactly **one edge per 2-line journal entry**,
432
+ confidence = 1.0. This avoids the Cartesian explosion (225 M edges
433
+ on 1 M JEs) that the legacy `cartesian` method produced, and gives
434
+ a clean topology for graph-ML training.
435
+
436
+ **Edge attributes.** `business_process` (P2P / O2C / R2R / H2R / A2R),
437
+ `is_fraud`, `is_anomaly`, `posting_date`, `amount`, `confidence`,
438
+ `predecessor_edge_id` (chains 2-line JEs into longer document flows).
439
+
440
+ **Drill-down.** Click any class node to see the underlying Level-3
441
+ sub-classes (`A.A.A` / `A.A.B` / …) and the top in/out flows.
442
+
443
+ **Source.** [GitHub: mivertowski/SyntheticData](https://github.com/mivertowski/SyntheticData) Β·
444
+ [Companion paper (SSRN)](https://ssrn.com/abstract=6538639)
445
+ """
446
+ )
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ streamlit==1.39.0
2
+ streamlit-agraph==0.0.45
3
+ pandas==2.2.3
4
+ pyarrow==17.0.0
5
+ huggingface_hub==0.26.2