489 lines
19 KiB
Python
489 lines
19 KiB
Python
import argparse
|
||
import csv
|
||
import json
|
||
import os
|
||
import random
|
||
import sys
|
||
from collections import defaultdict
|
||
|
||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||
|
||
from agentic.data_analysis_env import AgenticToolEnv, get_agentic_tools
|
||
|
||
|
||
CHANNELS = ["抖音", "微信", "百度", "小红书", "自然流量"]
|
||
CATEGORIES = ["美妆", "数码", "服饰", "食品", "家居"]
|
||
PROVINCES = ["广东", "浙江", "北京", "上海", "四川", "湖北"]
|
||
|
||
METRIC_DOCS = [
|
||
{
|
||
"id": "metric_refund_amount",
|
||
"title": "退款金额",
|
||
"text": "退款金额使用 orders 表的 refund_amount 字段求和;按渠道、品类、省份或时间过滤后再聚合。",
|
||
},
|
||
{
|
||
"id": "metric_net_revenue",
|
||
"title": "净收入",
|
||
"text": "净收入 = GMV - 退款金额。GMV 使用 orders.gmv,退款金额使用 orders.refund_amount。",
|
||
},
|
||
{
|
||
"id": "metric_refund_rate",
|
||
"title": "退款率",
|
||
"text": "退款率 = 退款金额 / GMV。按品类或渠道对 refund_amount 与 gmv 分别求和后再相除。",
|
||
},
|
||
{
|
||
"id": "metric_roas",
|
||
"title": "ROAS",
|
||
"text": "ROAS = GMV / 广告花费。GMV 来自 orders 表,广告花费来自 marketing 表的 spend 字段。",
|
||
},
|
||
{
|
||
"id": "metric_new_users",
|
||
"title": "新增用户",
|
||
"text": "新增用户使用 users 表的 new_users 字段求和;活跃用户使用 active_users 字段求和。",
|
||
},
|
||
{
|
||
"id": "schema_orders",
|
||
"title": "orders 表字段",
|
||
"text": "orders 包含 month、quarter、channel、category、province、gmv、refund_amount、orders_count。",
|
||
},
|
||
{
|
||
"id": "schema_marketing",
|
||
"title": "marketing 表字段",
|
||
"text": "marketing 包含 month、quarter、channel、spend、impressions、clicks。",
|
||
},
|
||
{
|
||
"id": "schema_users",
|
||
"title": "users 表字段",
|
||
"text": "users 包含 month、quarter、channel、province、active_users、new_users。",
|
||
},
|
||
]
|
||
|
||
|
||
def mkdir(path):
|
||
os.makedirs(path, exist_ok=True)
|
||
|
||
|
||
def write_csv(path, rows):
|
||
if not rows:
|
||
raise ValueError(f"empty csv rows: {path}")
|
||
with open(path, "w", encoding="utf-8", newline="") as f:
|
||
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
||
writer.writeheader()
|
||
writer.writerows(rows)
|
||
|
||
|
||
def relpath(path):
|
||
cwd = os.getcwd()
|
||
try:
|
||
return os.path.relpath(path, cwd)
|
||
except ValueError:
|
||
return path
|
||
|
||
|
||
def quarter_of(month):
|
||
return f"Q{(month - 1) // 3 + 1}"
|
||
|
||
|
||
def generate_tables(output_dir, seed):
|
||
rng = random.Random(seed)
|
||
table_dir = os.path.join(output_dir, "tables")
|
||
mkdir(table_dir)
|
||
|
||
orders = []
|
||
for month in range(1, 13):
|
||
season = 1.0 + (0.18 if month in {6, 11, 12} else 0.0) + (0.08 if month in {1, 2} else 0.0)
|
||
for channel_idx, channel in enumerate(CHANNELS):
|
||
for category_idx, category in enumerate(CATEGORIES):
|
||
for province_idx, province in enumerate(PROVINCES):
|
||
base = 42000 + channel_idx * 4600 + category_idx * 3100 + province_idx * 1800
|
||
gmv = round((base + rng.uniform(-5200, 6200)) * season, 2)
|
||
refund_rate = 0.025 + 0.006 * category_idx + 0.003 * channel_idx + rng.uniform(0.0, 0.018)
|
||
refund_amount = round(gmv * refund_rate, 2)
|
||
orders_count = max(1, int(gmv / (90 + category_idx * 12 + rng.uniform(-8, 8))))
|
||
orders.append(
|
||
{
|
||
"month": month,
|
||
"quarter": quarter_of(month),
|
||
"channel": channel,
|
||
"category": category,
|
||
"province": province,
|
||
"gmv": gmv,
|
||
"refund_amount": refund_amount,
|
||
"orders_count": orders_count,
|
||
}
|
||
)
|
||
|
||
marketing = []
|
||
for month in range(1, 13):
|
||
for channel_idx, channel in enumerate(CHANNELS):
|
||
spend = round(18000 + channel_idx * 3500 + month * 420 + rng.uniform(-1800, 2200), 2)
|
||
clicks = max(1, int(spend / (2.2 + channel_idx * 0.25 + rng.uniform(-0.2, 0.25))))
|
||
impressions = clicks * rng.randint(18, 36)
|
||
marketing.append(
|
||
{
|
||
"month": month,
|
||
"quarter": quarter_of(month),
|
||
"channel": channel,
|
||
"spend": spend,
|
||
"impressions": impressions,
|
||
"clicks": clicks,
|
||
}
|
||
)
|
||
|
||
users = []
|
||
for month in range(1, 13):
|
||
for channel_idx, channel in enumerate(CHANNELS):
|
||
for province_idx, province in enumerate(PROVINCES):
|
||
active_users = max(100, int(5200 + month * 110 + channel_idx * 520 + province_idx * 260 + rng.uniform(-420, 520)))
|
||
new_users = max(20, int(active_users * (0.08 + channel_idx * 0.008 + rng.uniform(0.0, 0.04))))
|
||
users.append(
|
||
{
|
||
"month": month,
|
||
"quarter": quarter_of(month),
|
||
"channel": channel,
|
||
"province": province,
|
||
"active_users": active_users,
|
||
"new_users": new_users,
|
||
}
|
||
)
|
||
|
||
paths = {
|
||
"orders": os.path.join(table_dir, "orders.csv"),
|
||
"marketing": os.path.join(table_dir, "marketing.csv"),
|
||
"users": os.path.join(table_dir, "users.csv"),
|
||
}
|
||
write_csv(paths["orders"], orders)
|
||
write_csv(paths["marketing"], marketing)
|
||
write_csv(paths["users"], users)
|
||
return {"orders": orders, "marketing": marketing, "users": users, "paths": paths}
|
||
|
||
|
||
def table_specs(paths):
|
||
return [
|
||
{
|
||
"name": "orders",
|
||
"path": relpath(paths["orders"]),
|
||
"description": "订单聚合表,包含 GMV、退款金额和订单数。",
|
||
},
|
||
{
|
||
"name": "marketing",
|
||
"path": relpath(paths["marketing"]),
|
||
"description": "投放聚合表,包含广告花费、曝光和点击。",
|
||
},
|
||
{
|
||
"name": "users",
|
||
"path": relpath(paths["users"]),
|
||
"description": "用户活跃聚合表,包含活跃用户和新增用户。",
|
||
},
|
||
]
|
||
|
||
|
||
def sum_field(rows, field, **filters):
|
||
total = 0.0
|
||
for row in rows:
|
||
if all(row.get(key) == value for key, value in filters.items()):
|
||
total += float(row[field])
|
||
return round(total, 2)
|
||
|
||
|
||
def top_group(rows, group_key, value_field, filters):
|
||
agg = defaultdict(float)
|
||
for row in rows:
|
||
if all(row.get(key) == value for key, value in filters.items()):
|
||
agg[row[group_key]] += float(row[value_field])
|
||
key, value = max(agg.items(), key=lambda item: item[1])
|
||
return key, round(value, 2)
|
||
|
||
|
||
def refund_rate_by_category(rows, month):
|
||
agg = defaultdict(lambda: {"gmv": 0.0, "refund": 0.0})
|
||
for row in rows:
|
||
if row["month"] == month:
|
||
agg[row["category"]]["gmv"] += float(row["gmv"])
|
||
agg[row["category"]]["refund"] += float(row["refund_amount"])
|
||
rates = {
|
||
category: values["refund"] / values["gmv"]
|
||
for category, values in agg.items()
|
||
if values["gmv"] > 0
|
||
}
|
||
category, rate = max(rates.items(), key=lambda item: item[1])
|
||
return category, round(rate, 4)
|
||
|
||
|
||
def monthly_gmv_by_channel(orders, month, channel):
|
||
return sum(float(row["gmv"]) for row in orders if row["month"] == month and row["channel"] == channel)
|
||
|
||
|
||
def marketing_spend(marketing, month, channel):
|
||
return sum(float(row["spend"]) for row in marketing if row["month"] == month and row["channel"] == channel)
|
||
|
||
|
||
def build_tool_call(name, arguments):
|
||
return {"name": name, "arguments": arguments}
|
||
|
||
|
||
def make_sample(sample_id, question, answer, checks, tables, expected_tools, actions, task_type, max_turns=4):
|
||
sample = {
|
||
"id": sample_id,
|
||
"question": question,
|
||
"tables": tables,
|
||
"documents": METRIC_DOCS,
|
||
"tools": ["retriever", "python_executor", "calculator"],
|
||
"expected_tools": expected_tools,
|
||
"answer": answer,
|
||
"checks": checks,
|
||
"max_turns": max_turns,
|
||
"metadata": {"task_type": task_type},
|
||
}
|
||
env = AgenticToolEnv(sample)
|
||
trajectory = []
|
||
for action in actions:
|
||
trajectory.append({"role": "assistant", "tool_call": action})
|
||
result = env.execute(action["name"], action["arguments"])
|
||
trajectory.append({"role": "tool", "name": action["name"], "content": result})
|
||
trajectory.append({"role": "assistant", "content": answer})
|
||
sample["expert_trajectory"] = trajectory
|
||
sample["tool_schemas"] = get_agentic_tools(sample["tools"])
|
||
return sample
|
||
|
||
|
||
def task_top_refund_channel(sample_id, tables, data, rng):
|
||
quarter = rng.choice(["Q1", "Q2", "Q3", "Q4"])
|
||
channel, value = top_group(data["orders"], "channel", "refund_amount", {"quarter": quarter})
|
||
question = f"2024年{quarter}退款金额最高的渠道是什么?请给出退款金额。"
|
||
code = (
|
||
f'orders = pd.read_csv("{tables[0]["path"]}")\n'
|
||
f'df = orders[orders["quarter"] == "{quarter}"]\n'
|
||
'agg = df.groupby("channel")["refund_amount"].sum().sort_values(ascending=False)\n'
|
||
'result = {"channel": agg.index[0], "refund_amount": round(float(agg.iloc[0]), 2)}'
|
||
)
|
||
answer = f"2024年{quarter}退款金额最高的渠道是{channel},退款金额为{value:.2f}元。"
|
||
return make_sample(
|
||
sample_id,
|
||
question,
|
||
answer,
|
||
{"contains": [channel], "number": value, "tolerance": 0.02},
|
||
tables,
|
||
["retriever", "python_executor"],
|
||
[
|
||
build_tool_call("retriever", {"query": "退款金额 指标口径 orders refund_amount", "top_k": 2}),
|
||
build_tool_call("python_executor", {"code": code}),
|
||
],
|
||
"top_refund_channel",
|
||
)
|
||
|
||
|
||
def task_net_revenue(sample_id, tables, data, rng):
|
||
month = rng.randint(1, 12)
|
||
channel = rng.choice(CHANNELS)
|
||
gmv = sum_field(data["orders"], "gmv", month=month, channel=channel)
|
||
refund = sum_field(data["orders"], "refund_amount", month=month, channel=channel)
|
||
value = round(gmv - refund, 2)
|
||
question = f"2024年{month}月{channel}渠道的净收入是多少?"
|
||
code = (
|
||
f'orders = pd.read_csv("{tables[0]["path"]}")\n'
|
||
f'df = orders[(orders["month"] == {month}) & (orders["channel"] == "{channel}")]\n'
|
||
'result = {"net_revenue": round(float(df["gmv"].sum() - df["refund_amount"].sum()), 2)}'
|
||
)
|
||
answer = f"2024年{month}月{channel}渠道的净收入为{value:.2f}元。"
|
||
return make_sample(
|
||
sample_id,
|
||
question,
|
||
answer,
|
||
{"contains": [channel], "number": value, "tolerance": 0.02},
|
||
tables,
|
||
["retriever", "python_executor"],
|
||
[
|
||
build_tool_call("retriever", {"query": "净收入 GMV 退款金额 计算口径", "top_k": 2}),
|
||
build_tool_call("python_executor", {"code": code}),
|
||
],
|
||
"net_revenue",
|
||
)
|
||
|
||
|
||
def task_refund_rate_category(sample_id, tables, data, rng):
|
||
month = rng.randint(1, 12)
|
||
category, rate = refund_rate_by_category(data["orders"], month)
|
||
percent = round(rate * 100, 2)
|
||
question = f"2024年{month}月退款率最高的品类是什么?退款率是多少?"
|
||
code = (
|
||
f'orders = pd.read_csv("{tables[0]["path"]}")\n'
|
||
f'df = orders[orders["month"] == {month}]\n'
|
||
'agg = df.groupby("category")[["refund_amount", "gmv"]].sum()\n'
|
||
'agg["refund_rate"] = agg["refund_amount"] / agg["gmv"]\n'
|
||
'top = agg.sort_values("refund_rate", ascending=False).iloc[0]\n'
|
||
'result = {"category": agg.sort_values("refund_rate", ascending=False).index[0], "refund_rate": round(float(top["refund_rate"] * 100), 2)}'
|
||
)
|
||
answer = f"2024年{month}月退款率最高的品类是{category},退款率为{percent:.2f}%。"
|
||
return make_sample(
|
||
sample_id,
|
||
question,
|
||
answer,
|
||
{"contains": [category], "number": percent, "tolerance": 0.02},
|
||
tables,
|
||
["retriever", "python_executor"],
|
||
[
|
||
build_tool_call("retriever", {"query": "退款率 指标口径 refund_amount gmv", "top_k": 2}),
|
||
build_tool_call("python_executor", {"code": code}),
|
||
],
|
||
"refund_rate_category",
|
||
)
|
||
|
||
|
||
def task_roas(sample_id, tables, data, rng):
|
||
month = rng.randint(1, 12)
|
||
channel = rng.choice([c for c in CHANNELS if c != "自然流量"])
|
||
gmv = monthly_gmv_by_channel(data["orders"], month, channel)
|
||
spend = marketing_spend(data["marketing"], month, channel)
|
||
value = round(gmv / spend, 2)
|
||
question = f"2024年{month}月{channel}渠道的ROAS是多少?"
|
||
code = (
|
||
f'orders = pd.read_csv("{tables[0]["path"]}")\n'
|
||
f'marketing = pd.read_csv("{tables[1]["path"]}")\n'
|
||
f'gmv = orders[(orders["month"] == {month}) & (orders["channel"] == "{channel}")]["gmv"].sum()\n'
|
||
f'spend = marketing[(marketing["month"] == {month}) & (marketing["channel"] == "{channel}")]["spend"].sum()\n'
|
||
'result = {"roas": round(float(gmv / spend), 2)}'
|
||
)
|
||
answer = f"2024年{month}月{channel}渠道的ROAS为{value:.2f}。"
|
||
return make_sample(
|
||
sample_id,
|
||
question,
|
||
answer,
|
||
{"contains": [channel], "number": value, "tolerance": 0.02},
|
||
tables,
|
||
["retriever", "python_executor"],
|
||
[
|
||
build_tool_call("retriever", {"query": "ROAS GMV 广告花费 指标口径", "top_k": 2}),
|
||
build_tool_call("python_executor", {"code": code}),
|
||
],
|
||
"roas",
|
||
)
|
||
|
||
|
||
def task_new_users(sample_id, tables, data, rng):
|
||
month = rng.randint(1, 12)
|
||
channel = rng.choice(CHANNELS)
|
||
province, value = top_group(data["users"], "province", "new_users", {"month": month, "channel": channel})
|
||
question = f"2024年{month}月{channel}渠道新增用户最多的省份是哪个?新增用户数是多少?"
|
||
code = (
|
||
f'users = pd.read_csv("{tables[2]["path"]}")\n'
|
||
f'df = users[(users["month"] == {month}) & (users["channel"] == "{channel}")]\n'
|
||
'agg = df.groupby("province")["new_users"].sum().sort_values(ascending=False)\n'
|
||
'result = {"province": agg.index[0], "new_users": int(agg.iloc[0])}'
|
||
)
|
||
answer = f"2024年{month}月{channel}渠道新增用户最多的省份是{province},新增用户数为{int(value)}。"
|
||
return make_sample(
|
||
sample_id,
|
||
question,
|
||
answer,
|
||
{"contains": [province], "number": int(value), "tolerance": 0.01},
|
||
tables,
|
||
["retriever", "python_executor"],
|
||
[
|
||
build_tool_call("retriever", {"query": "新增用户 users new_users 字段口径", "top_k": 2}),
|
||
build_tool_call("python_executor", {"code": code}),
|
||
],
|
||
"new_users",
|
||
)
|
||
|
||
|
||
def task_incremental_gmv(sample_id, tables, data, rng):
|
||
month = rng.randint(1, 12)
|
||
channel = rng.choice([c for c in CHANNELS if c != "自然流量"])
|
||
pct = rng.choice([10, 12, 15, 20])
|
||
gmv = monthly_gmv_by_channel(data["orders"], month, channel)
|
||
spend = marketing_spend(data["marketing"], month, channel)
|
||
roas = gmv / spend
|
||
incremental = round(spend * pct / 100 * roas, 2)
|
||
question = f"如果2024年{month}月{channel}渠道广告花费增加{pct}%,且ROAS保持不变,预计GMV增加多少?"
|
||
code = (
|
||
f'orders = pd.read_csv("{tables[0]["path"]}")\n'
|
||
f'marketing = pd.read_csv("{tables[1]["path"]}")\n'
|
||
f'gmv = orders[(orders["month"] == {month}) & (orders["channel"] == "{channel}")]["gmv"].sum()\n'
|
||
f'spend = marketing[(marketing["month"] == {month}) & (marketing["channel"] == "{channel}")]["spend"].sum()\n'
|
||
'result = {"spend": round(float(spend), 2), "roas": round(float(gmv / spend), 4)}'
|
||
)
|
||
expression = f"{spend:.2f} * {pct} / 100 * {roas:.6f}"
|
||
answer = f"预计GMV增加{incremental:.2f}元。"
|
||
return make_sample(
|
||
sample_id,
|
||
question,
|
||
answer,
|
||
{"contains": ["GMV"], "number": incremental, "tolerance": 0.05},
|
||
tables,
|
||
["retriever", "python_executor", "calculator"],
|
||
[
|
||
build_tool_call("retriever", {"query": "ROAS GMV 广告花费 预算增加 计算口径", "top_k": 2}),
|
||
build_tool_call("python_executor", {"code": code}),
|
||
build_tool_call("calculator", {"expression": expression}),
|
||
],
|
||
"incremental_gmv",
|
||
max_turns=5,
|
||
)
|
||
|
||
|
||
TASK_BUILDERS = [
|
||
task_top_refund_channel,
|
||
task_net_revenue,
|
||
task_refund_rate_category,
|
||
task_roas,
|
||
task_new_users,
|
||
task_incremental_gmv,
|
||
]
|
||
|
||
|
||
def write_jsonl(path, rows):
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
for row in rows:
|
||
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||
|
||
|
||
def build_split(name, size, tables, data, seed):
|
||
rng = random.Random(seed)
|
||
rows = []
|
||
for i in range(size):
|
||
task_fn = TASK_BUILDERS[i % len(TASK_BUILDERS)]
|
||
sample_id = f"agentic_{name}_{i:06d}"
|
||
rows.append(task_fn(sample_id, tables, data, rng))
|
||
rng.shuffle(rows)
|
||
return rows
|
||
|
||
|
||
def parse_args():
|
||
parser = argparse.ArgumentParser(description="Build Agentic DataAnalysis tasks")
|
||
parser.add_argument("--output_dir", default="dataset/agentic_data")
|
||
parser.add_argument("--train_size", type=int, default=30000)
|
||
parser.add_argument("--dev_size", type=int, default=1000)
|
||
parser.add_argument("--test_size", type=int, default=1000)
|
||
parser.add_argument("--seed", type=int, default=42)
|
||
return parser.parse_args()
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
mkdir(args.output_dir)
|
||
data = generate_tables(args.output_dir, args.seed)
|
||
tables = table_specs(data["paths"])
|
||
splits = {
|
||
"train": build_split("train", args.train_size, tables, data, args.seed + 1),
|
||
"dev": build_split("dev", args.dev_size, tables, data, args.seed + 2),
|
||
"test": build_split("test", args.test_size, tables, data, args.seed + 3),
|
||
}
|
||
for split, rows in splits.items():
|
||
write_jsonl(os.path.join(args.output_dir, f"{split}.jsonl"), rows)
|
||
manifest = {
|
||
"task": "AgenticDataAnalysis",
|
||
"seed": args.seed,
|
||
"splits": {split: len(rows) for split, rows in splits.items()},
|
||
"tables": tables,
|
||
"tools": [tool["function"]["name"] for tool in get_agentic_tools()],
|
||
"task_types": [fn.__name__.replace("task_", "") for fn in TASK_BUILDERS],
|
||
}
|
||
with open(os.path.join(args.output_dir, "manifest.json"), "w", encoding="utf-8") as f:
|
||
json.dump(manifest, f, ensure_ascii=False, indent=2)
|
||
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|