Commit ·
700dbfe
1
Parent(s): 73c2d2d
space files uploaded
Browse files- README.md +15 -6
- app.py +236 -0
- requirements.txt +8 -0
README.md
CHANGED
|
@@ -1,15 +1,24 @@
|
|
| 1 |
---
|
| 2 |
title: Modular Model Composition Explorer
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
license: apache-2.0
|
| 12 |
short_description: Profile adapter compatibility and VRAM cost for models
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Modular Model Composition Explorer
|
| 3 |
+
emoji: 🧩
|
| 4 |
+
colorFrom: indigo
|
| 5 |
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 4.26.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
|
|
|
| 10 |
short_description: Profile adapter compatibility and VRAM cost for models
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Modular Model Composition Explorer
|
| 14 |
+
|
| 15 |
+
This Hugging Face Space serves as a production-grade developer environment to profile multi-tenant adapter networks, calculate tensor shapes, and analyze VRAM efficiency for runtime model architectures. It allows engineers to simulate complex modular compositions without setting up heavy local hardware infrastructure.
|
| 16 |
+
|
| 17 |
+
## Core Capabilities
|
| 18 |
+
|
| 19 |
+
- **Adapter Compatibility Checker**: Empirically verify structural alignment (hidden size, attention heads, target modules) between base models and PEFT adapters using real-time Hugging Face Hub metadata.
|
| 20 |
+
- **Multi-Tenant Router Simulator**: Profile runtime routing overhead and performance bottlenecks for dynamic adapter configurations (e.g., Token Gating, MoE).
|
| 21 |
+
- **VRAM & Resource Calculator**: Calculate precise infrastructure footprints, including KV Cache overhead and multi-tenant scaling factors across various quantization levels (FP16, INT8, INT4).
|
| 22 |
+
|
| 23 |
+
## Technical Implementation
|
| 24 |
+
The application uses **Gradio** for the interactive interface, **huggingface_hub** for metadata retrieval, and **Plotly** for high-fidelity architectural visualizations. It focuses on structural and resource profiling, abstracting away the underlying tensor computations for rapid prototyping.
|
app.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import plotly.express as px
|
| 4 |
+
import plotly.graph_objects as go
|
| 5 |
+
import numpy as np
|
| 6 |
+
from scipy.stats import zipfian
|
| 7 |
+
from huggingface_hub import HfApi
|
| 8 |
+
import requests
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
from typing import Dict, Optional, Tuple, List
|
| 12 |
+
|
| 13 |
+
# --- Configuration & Styling ---
|
| 14 |
+
LOG_FORMAT = "%(asctime)s - %(levelname)s - %(message)s"
|
| 15 |
+
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
|
| 16 |
+
|
| 17 |
+
CUSTOM_CSS = """
|
| 18 |
+
.dashboard-card {
|
| 19 |
+
border-radius: 8px;
|
| 20 |
+
padding: 1.5rem;
|
| 21 |
+
border: 1px solid #e0e0e0;
|
| 22 |
+
background: #ffffff;
|
| 23 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
| 24 |
+
}
|
| 25 |
+
.status-success { border-left: 5px solid #28a745 !important; }
|
| 26 |
+
.status-warning { border-left: 5px solid #ffc107 !important; }
|
| 27 |
+
.status-error { border-left: 5px solid #dc3545 !important; }
|
| 28 |
+
.metric-val { font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: 1.2rem; }
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
class ModelProfiler:
|
| 32 |
+
"""Core logic engine for model architectural and resource profiling."""
|
| 33 |
+
|
| 34 |
+
def __init__(self):
|
| 35 |
+
self.api = HfApi()
|
| 36 |
+
self.session = requests.Session()
|
| 37 |
+
|
| 38 |
+
def fetch_config(self, repo_id: str, filename: str = "config.json") -> Optional[Dict]:
|
| 39 |
+
try:
|
| 40 |
+
url = self.api.hf_hub_url(repo_id, filename)
|
| 41 |
+
resp = self.session.get(url, timeout=5)
|
| 42 |
+
return resp.json() if resp.status_code == 200 else None
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logging.error(f"Failed to fetch {filename} for {repo_id}: {e}")
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
def validate_architecture(self, base_id: str, adapter_id: str) -> Tuple[str, str]:
|
| 48 |
+
base_cfg = self.fetch_config(base_id)
|
| 49 |
+
adapt_cfg = self.fetch_config(adapter_id, "adapter_config.json")
|
| 50 |
+
|
| 51 |
+
if not base_cfg or not adapt_cfg:
|
| 52 |
+
return self._render_status("error", "Metadata Fetch Failure", "Unable to retrieve architectural configs from Hugging Face Hub."), ""
|
| 53 |
+
|
| 54 |
+
# Architectural Heuristics
|
| 55 |
+
b_type = base_cfg.get("model_type", "unknown")
|
| 56 |
+
b_hidden = base_cfg.get("hidden_size", 0)
|
| 57 |
+
b_layers = base_cfg.get("num_hidden_layers", 0)
|
| 58 |
+
b_heads = base_cfg.get("num_attention_heads", 0)
|
| 59 |
+
|
| 60 |
+
a_base_path = adapt_cfg.get("base_model_name_or_path", "unknown")
|
| 61 |
+
target_modules = adapt_cfg.get("target_modules", [])
|
| 62 |
+
if isinstance(target_modules, str): target_modules = [target_modules]
|
| 63 |
+
|
| 64 |
+
# Compatibility Scoring
|
| 65 |
+
mismatch_reasons = []
|
| 66 |
+
if b_hidden != adapt_cfg.get("target_hidden_size", b_hidden): # Some PEFT configs have this
|
| 67 |
+
mismatch_reasons.append(f"Dimension mismatch: Base ({b_hidden}) != Adapter target.")
|
| 68 |
+
|
| 69 |
+
# Check target modules against architecture
|
| 70 |
+
arch_targets = {
|
| 71 |
+
"llama": ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
|
| 72 |
+
"qwen2": ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
|
| 73 |
+
"mistral": ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
valid_for_arch = any(any(t in str(m) for t in arch_targets.get(b_type, [])) for m in target_modules)
|
| 77 |
+
if not valid_for_arch:
|
| 78 |
+
mismatch_reasons.append(f"Target module naming convention may not match '{b_type}' architecture.")
|
| 79 |
+
|
| 80 |
+
status = "success" if not mismatch_reasons else "warning"
|
| 81 |
+
title = "Architectural Alignment Confirmed" if status == "success" else "Potential Compatibility Drift"
|
| 82 |
+
|
| 83 |
+
details_md = f"""
|
| 84 |
+
### Structural Audit: `{base_id}`
|
| 85 |
+
- **Core Architecture**: `{b_type.upper()}`
|
| 86 |
+
- **Parameter Dimensions**: `{b_hidden}` hidden size | `{b_heads}` attention heads
|
| 87 |
+
- **Depth**: `{b_layers}` Transformer blocks
|
| 88 |
+
|
| 89 |
+
### Composition Metadata: `{adapter_id}`
|
| 90 |
+
- **PEFT Type**: `{adapt_cfg.get('peft_type', 'LORA')}`
|
| 91 |
+
- **Rank (r)**: `{adapt_cfg.get('r', 'N/A')}` | **Alpha**: `{adapt_cfg.get('lora_alpha', 'N/A')}`
|
| 92 |
+
- **Target Projections**: `{', '.join(target_modules[:5])}{'...' if len(target_modules) > 5 else ''}`
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
return self._render_status(status, title, " | ".join(mismatch_reasons) if mismatch_reasons else "All structural tensors are dimensionally compatible."), details_md
|
| 96 |
+
|
| 97 |
+
def simulate_routing_dynamics(self, style: str, count: int, threshold: float) -> Tuple[go.Figure, go.Figure]:
|
| 98 |
+
# Latency Simulation with PCIe/Interconnect overhead
|
| 99 |
+
x = np.arange(1, 13)
|
| 100 |
+
# Latency = Base (10ms) + (Count^1.6 * ThresholdFactor) + Jitter
|
| 101 |
+
base_latency = 12
|
| 102 |
+
latencies = base_latency + (x ** 1.6) * (1.1 - threshold) * 8
|
| 103 |
+
|
| 104 |
+
fig_lat = go.Figure()
|
| 105 |
+
fig_lat.add_trace(go.Scatter(x=x, y=latencies, mode='lines+markers', name='System Latency',
|
| 106 |
+
line=dict(color='#6366f1', width=3),
|
| 107 |
+
marker=dict(size=8, symbol='diamond')))
|
| 108 |
+
fig_lat.add_vline(x=count, line_dash="dash", line_color="#ef4444", annotation_text="Active Load")
|
| 109 |
+
fig_lat.update_layout(title="Multi-Tenant Routing Overhead", xaxis_title="Concurrent Adapters",
|
| 110 |
+
yaxis_title="P99 Latency (ms)", template="plotly_white", margin=dict(l=20, r=20, t=40, b=20))
|
| 111 |
+
|
| 112 |
+
# Throughput Simulation using Zipfian Distribution (Realistic for MoE/Multi-LoRA)
|
| 113 |
+
a = 1.2 # Zipf parameter
|
| 114 |
+
pos = np.arange(1, count + 1)
|
| 115 |
+
weights = zipfian.pmf(pos, a, count)
|
| 116 |
+
weights = weights / weights.sum() * 100
|
| 117 |
+
|
| 118 |
+
fig_dist = go.Figure(data=[go.Bar(x=[f"Adp_{i}" for i in pos], y=weights,
|
| 119 |
+
marker_color='#8b5cf6', text=[f"{v:.1f}%" for v in weights], textposition='auto')])
|
| 120 |
+
fig_dist.update_layout(title="Runtime Token Affinity Distribution", xaxis_title="Adapter Slot",
|
| 121 |
+
yaxis_title="Traffic Share (%)", template="plotly_white", margin=dict(l=20, r=20, t=40, b=20))
|
| 122 |
+
|
| 123 |
+
return fig_lat, fig_dist
|
| 124 |
+
|
| 125 |
+
def calculate_resource_footprint(self, scale: str, quant: str, adapters: int, ctx: int) -> Tuple[go.Figure, str]:
|
| 126 |
+
# Constants
|
| 127 |
+
GB = 1.073741824 # Binary GB
|
| 128 |
+
params_b = float(scale.replace("B", ""))
|
| 129 |
+
|
| 130 |
+
# Bytes per weight
|
| 131 |
+
prec_map = {"FP16/BF16": 2, "INT8": 1, "INT4 (GPTQ/AWQ)": 0.5, "NF4 (QLoRA)": 0.5}
|
| 132 |
+
bpp = prec_map[quant]
|
| 133 |
+
|
| 134 |
+
# Weights Memory
|
| 135 |
+
mem_weights = (params_b * bpp) # Result in GB
|
| 136 |
+
|
| 137 |
+
# KV Cache Logic: 2 * layers * heads * head_dim * bytes * context * batch
|
| 138 |
+
# Heuristic for 8B model: 32 layers, 32 heads, 128 head_dim
|
| 139 |
+
# Simplified: ~0.5MB per token for 7B-8B models in FP16
|
| 140 |
+
kv_per_token_gb = (0.5 / 1024) * (bpp / 2) # Adjusted for quantization
|
| 141 |
+
mem_kv = kv_per_token_gb * ctx
|
| 142 |
+
|
| 143 |
+
# Adapter Overhead: 128MB base + 32MB per 'r' rank (assuming r=16 avg)
|
| 144 |
+
mem_adapters = (0.12 * adapters) + 0.05
|
| 145 |
+
|
| 146 |
+
total = mem_weights + mem_kv + mem_adapters
|
| 147 |
+
|
| 148 |
+
fig = go.Figure(data=[
|
| 149 |
+
go.Bar(name="Static Weights", x=["Memory Layout"], y=[mem_weights], marker_color='#1e293b'),
|
| 150 |
+
go.Bar(name="Dynamic KV Cache", x=["Memory Layout"], y=[mem_kv], marker_color='#3b82f6'),
|
| 151 |
+
go.Bar(name="Adapter Runtime", x=["Memory Layout"], y=[mem_adapters], marker_color='#10b981')
|
| 152 |
+
])
|
| 153 |
+
fig.update_layout(barmode='stack', title="Infrastructure VRAM Allocation", yaxis_title="VRAM (GB)",
|
| 154 |
+
template="plotly_white", showlegend=True, margin=dict(l=20, r=20, t=40, b=20))
|
| 155 |
+
|
| 156 |
+
# Efficiency Analysis
|
| 157 |
+
dedicated_cost = adapters * (mem_weights + mem_kv)
|
| 158 |
+
savings = ((dedicated_cost - total) / dedicated_cost) * 100
|
| 159 |
+
|
| 160 |
+
report_md = f"""
|
| 161 |
+
<div class='dashboard-card'>
|
| 162 |
+
<h3>📊 Resource Summary</h3>
|
| 163 |
+
<div style='display: flex; gap: 2rem;'>
|
| 164 |
+
<div><p>Total Provisioned VRAM</p><p class='metric-val'>{total:.2f} GB</p></div>
|
| 165 |
+
<div><p>KV Cache Overhead</p><p class='metric-val'>{mem_kv*1024:.0f} MB</p></div>
|
| 166 |
+
<div><p>Composition Efficiency</p><p class='metric-val' style='color: #10b981;'>{savings:.1f}%</p></div>
|
| 167 |
+
</div>
|
| 168 |
+
<p style='margin-top: 1rem; font-size: 0.9rem; color: #64748b;'>
|
| 169 |
+
By leveraging <b>Dynamic Adapter Hot-Swapping</b>, the infrastructure supports {adapters} virtual instances
|
| 170 |
+
using only {total/mem_weights:.1f}x the base model memory.
|
| 171 |
+
</p>
|
| 172 |
+
</div>
|
| 173 |
+
"""
|
| 174 |
+
return fig, report_md
|
| 175 |
+
|
| 176 |
+
def _render_status(self, kind: str, title: str, msg: str) -> str:
|
| 177 |
+
color_class = f"status-{kind}"
|
| 178 |
+
return f"""
|
| 179 |
+
<div class='dashboard-card {color_class}'>
|
| 180 |
+
<h3 style='margin: 0;'>{title}</h3>
|
| 181 |
+
<p style='margin: 0.5rem 0 0 0;'>{msg}</p>
|
| 182 |
+
</div>
|
| 183 |
+
"""
|
| 184 |
+
|
| 185 |
+
# --- UI Construction ---
|
| 186 |
+
profiler = ModelProfiler()
|
| 187 |
+
|
| 188 |
+
with gr.Blocks(theme=gr.themes.Default(primary_hue="indigo", font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif"]), css=CUSTOM_CSS) as demo:
|
| 189 |
+
gr.HTML("<div style='text-align: center; padding: 2rem;'><h1>Modular Model Composition Explorer</h1><p>Production-Grade Profiling for Multi-Tenant Adapter Networks</p></div>")
|
| 190 |
+
|
| 191 |
+
with gr.Tabs():
|
| 192 |
+
with gr.Tab("🛠 Architectural Audit"):
|
| 193 |
+
with gr.Row():
|
| 194 |
+
with gr.Column(scale=1):
|
| 195 |
+
gr.Markdown("### Source Parameters")
|
| 196 |
+
base_input = gr.Textbox(label="Base Model Repository", placeholder="e.g. meta-llama/Llama-3-8B", value="meta-llama/Meta-Llama-3-8B")
|
| 197 |
+
adapter_input = gr.Textbox(label="Adapter Repository", placeholder="e.g. alignment-handbook/zephyr-7b-lora", value="FinGPT/fingpt-forecaster_llama3-8b_lora")
|
| 198 |
+
audit_btn = gr.Button("Execute Audit", variant="primary")
|
| 199 |
+
with gr.Column(scale=2):
|
| 200 |
+
status_out = gr.HTML(profiler._render_status("success", "System Ready", "Awaiting repository identifiers for structural validation."))
|
| 201 |
+
details_out = gr.Markdown("### Structural Details\nAudit results will appear here.")
|
| 202 |
+
|
| 203 |
+
audit_btn.click(profiler.validate_architecture, [base_input, adapter_input], [status_out, details_out])
|
| 204 |
+
|
| 205 |
+
with gr.Tab("🚦 Routing Simulator"):
|
| 206 |
+
with gr.Row():
|
| 207 |
+
with gr.Column(scale=1):
|
| 208 |
+
gr.Markdown("### Controller Settings")
|
| 209 |
+
r_style = gr.Radio(["Token-Level Gating", "Layer-Stitching", "Dynamic Multi-LoRA"], label="Routing Protocol", value="Token-Level Gating")
|
| 210 |
+
r_count = gr.Slider(1, 12, step=1, value=4, label="Concurrent Active Adapters")
|
| 211 |
+
r_thresh = gr.Slider(0.5, 0.99, step=0.01, value=0.85, label="Router Gating Confidence")
|
| 212 |
+
sim_btn = gr.Button("Calculate Routing Dynamics", variant="primary")
|
| 213 |
+
with gr.Column(scale=2):
|
| 214 |
+
with gr.Row():
|
| 215 |
+
plot_lat = gr.Plot()
|
| 216 |
+
plot_dist = gr.Plot()
|
| 217 |
+
|
| 218 |
+
sim_btn.click(profiler.simulate_routing_dynamics, [r_style, r_count, r_thresh], [plot_lat, plot_dist])
|
| 219 |
+
|
| 220 |
+
with gr.Tab("💾 Infrastructure Planner"):
|
| 221 |
+
with gr.Row():
|
| 222 |
+
with gr.Column(scale=1):
|
| 223 |
+
gr.Markdown("### Provisioning Specs")
|
| 224 |
+
v_scale = gr.Dropdown(["7B", "8B", "13B", "34B", "70B"], value="8B", label="Model Scale (Billion Params)")
|
| 225 |
+
v_quant = gr.Dropdown(["FP16/BF16", "INT8", "INT4 (GPTQ/AWQ)", "NF4 (QLoRA)"], value="INT8", label="Quantization Precision")
|
| 226 |
+
v_adapters = gr.Slider(1, 20, step=1, value=5, label="Max Resident Adapters")
|
| 227 |
+
v_ctx = gr.Slider(512, 32768, step=512, value=4096, label="Target Context Length (Tokens)")
|
| 228 |
+
calc_btn = gr.Button("Generate Resource Report", variant="primary")
|
| 229 |
+
with gr.Column(scale=2):
|
| 230 |
+
vram_plot = gr.Plot()
|
| 231 |
+
report_html = gr.HTML("<div class='dashboard-card'>Awaiting configuration to generate report.</div>")
|
| 232 |
+
|
| 233 |
+
calc_btn.click(profiler.calculate_resource_footprint, [v_scale, v_quant, v_adapters, v_ctx], [vram_plot, report_html])
|
| 234 |
+
|
| 235 |
+
if __name__ == "__main__":
|
| 236 |
+
demo.launch()
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.26.0
|
| 2 |
+
huggingface_hub>=0.22.0
|
| 3 |
+
peft>=0.10.0
|
| 4 |
+
plotly>=5.20.0
|
| 5 |
+
pandas>=2.2.0
|
| 6 |
+
pyyaml>=6.0.1
|
| 7 |
+
requests>=2.31.0
|
| 8 |
+
scipy>=1.12.0
|