diff --git a/.github/workflows/minionerec-quick-start.yml b/.github/workflows/minionerec-quick-start.yml new file mode 100644 index 000000000..2d1a612c6 --- /dev/null +++ b/.github/workflows/minionerec-quick-start.yml @@ -0,0 +1,94 @@ +# MiniOneRec quick start guard - project thin trigger. +# +# Calls the common engine .github/workflows/quick-start-template.yml; this +# file only declares what varies per project: schedule, concurrency, runner, +# container image, upstream repo, the pinned ref, the monitored doc and the +# test entry command. The guard loop, cache I/O, state relay and publishing +# all live in the engine - see its `workflow_call.inputs` block for the full +# contract. +# +# Cache keys (quick-start-monitor-state-minionerec_*) and artifact names +# (minionerec-quick-start-) are derived from project=minionerec. +# +# Upstream shape: MiniOneRec is research code on `main`, no release tags, so +# fixed_ref=main - the engine resolves it to the branch tip SHA on every run, +# which is also the monitor's change key. +# +# Scope: single card (910B), three stages on one 0.5B model - SFT (one epoch, +# sample=600 per sub-dataset), constrained-decoding evaluation (CC must be 0) +# and one GRPO epoch. `sft.py` / `evaluate.py` stay byte-identical: the +# shim is a `sitecustomize.py` under /tmp that only the evaluation and RL +# commands see through PYTHONPATH. `rl.py` gets three single-line edits via +# sed (bitsandbytes has no NPU backend; `sample = -1` and `sample=10000` +# would each expand the training set to tens of thousands of steps). +# Measured: ~44 min end to end. + +name: minionerec-quick-start + +concurrency: + # format() is load-bearing: a '||' between 'manual-' and + # github.run_id would short-circuit on the truthy literal and + # every dispatch would share one 'manual-' group. + group: ${{ github.event_name == 'schedule' && 'minionerec-quick-start-schedule' || format('manual-{0}', github.run_id) }} + # cancel-in-progress: false because (1) a schedule run cancelled + # mid-way loses its outcome writeback - the outcome is what makes + # the retry mechanism work, and the 'if: always()' guard isn't + # enough when the container is being torn down; (2) dispatch / PR + # runs already live in unique groups so there's nothing to cancel. + cancel-in-progress: false + +on: + schedule: + # Twice a day, 12 h apart: UTC 11:50 and 23:50 + # (Beijing +8). All quick-start guards share this cadence; the + # (hour, minute) slot is staggered across the guards so the NPU + # runners do not all fire at the same time. + - cron: '50 11,23 * * *' + workflow_dispatch: + # PR trigger: docs/tests changes get a guard run before merge. paths filter + # avoids burning the self-hosted NPU runner on unrelated PRs. + # `pull_request` (not `pull_request_target`): contents: read is enough, no + # write-token risk. + pull_request: + branches: [main] + paths: + - 'sources/minionerec/**' + - 'tests/minionerec/**' + - '.github/workflows/minionerec-quick-start.yml' + +permissions: + contents: read + +jobs: + minionerec-quick-start: + uses: ./.github/workflows/quick-start-template.yml + with: + # Namespaces cache keys (quick-start-monitor-state-minionerec_*), + # artifacts (minionerec-quick-start-) and the test working dir. + project: minionerec + # Self-hosted NPU runner for the test job only; the engine pins the + # cache I/O jobs (restore-cache / publish-and-persist) to + # GitHub-hosted ubuntu-latest. The quick start is single-card + # (ASCEND_RT_VISIBLE_DEVICES=0). + test_runner: '["linux-aarch64-a2-1"]' + image: swr.cn-south-1.myhuaweicloud.com/ascendhub/cann:9.1.0-910b-ubuntu22.04-py3.12 + container_options: '' + # SFT ~11 min, evaluation ~1.5 min, RL ~14 min, plus the clone and the + # HuggingFace downloads; 120 min covers a cold run of the whole doc. + timeout_minutes: 120 + upstream_repo: AkaliKong/MiniOneRec + # No release tags upstream; `main` is the branch the README documents. + fixed_ref: main + # Doc URL points at the upstream Ascend/docs repo. {0} is filled by the + # engine: PR head SHA on PR runs, 'main' otherwise. Same-repo PRs (head + # SHA exists on Ascend/docs) test the PR-version of the doc; fork PRs + # (head SHA on a fork) 404 on doc fetch - accepted, since the content + # lands on Ascend/docs post-merge. + doc_url: 'https://raw.githubusercontent.com/Ascend/docs/{0}/sources/minionerec/quick_start.md' + doc_path: https://github.com/Ascend/docs/blob/main/sources/minionerec/quick_start.md + # cwd is the repo root inside the `workflows` checkout (matches the + # engine template's `working-directory: workflows`); env contract + # (MONITORED_DOC_URL / UPSTREAM_REF / NPU_READY) is injected by the + # engine. The test subclass pins cwd to /root/minionerec-test and + # sources CANN; every business step is in the doc. + test_command: python -m unittest tests.minionerec.test_quick_start_ascend -v 2>&1 diff --git a/_static/images/minionerec.svg b/_static/images/minionerec.svg new file mode 100644 index 000000000..42e31040f --- /dev/null +++ b/_static/images/minionerec.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/conf.py b/conf.py index dcf2f61bc..3a788907d 100644 --- a/conf.py +++ b/conf.py @@ -112,6 +112,7 @@ 'sources/wenet/quick_start.md', 'sources/torchvision/quick_start.md', 'sources/rtp_llm/quick_start.md', + 'sources/minionerec/quick_start.md', 'sources/pyg/quick_start.md'] diff --git a/index.rst b/index.rst index d7b3758e6..03e69b1ab 100644 --- a/index.rst +++ b/index.rst @@ -224,6 +224,13 @@ + +
+

MiniOneRec

+

生成式推荐框架:把商品编码成 3 层语义 ID,再用 LLM 生成推荐结果。在昇腾上完成 SFT、约束解码评估与推荐导向 RL。

+ +
+

ROLL

@@ -653,6 +660,7 @@ sources/colossalai/index.rst sources/mmengine/index.rst sources/ms-swift/index.rst + sources/minionerec/index.rst sources/peft/index.md sources/roll/index.rst sources/speculators/index.rst diff --git a/sources/minionerec/index.rst b/sources/minionerec/index.rst new file mode 100644 index 000000000..89892f3b6 --- /dev/null +++ b/sources/minionerec/index.rst @@ -0,0 +1,2 @@ +.. include:: quick_start.md + :parser: myst_parser.sphinx_ diff --git a/sources/minionerec/quick_start.md b/sources/minionerec/quick_start.md new file mode 100644 index 000000000..34d7faf00 --- /dev/null +++ b/sources/minionerec/quick_start.md @@ -0,0 +1,408 @@ +# MiniOneRec + +[MiniOneRec](https://github.com/AkaliKong/MiniOneRec) 是全开源的生成式推荐框架:把每件商品编码成 3 层语义 ID(SID),再用 LLM 按「下一个 SID」的方式生成推荐结果。本文在单张昇腾 NPU 上完成 SID 监督微调(SFT)、约束解码评估与推荐导向 RL 三个阶段。 + +前两个阶段不改上游源码:`sft.py` 的设备由 transformers 自己选到 `npu:0`,`evaluate.py` 则把设备判定写在模块级,靠一条只在评估与 RL 命令上生效的 `PYTHONPATH` 注入 `transfer_to_npu`。RL 阶段有三处写死值与单卡运行冲突,用一条 `sed` 就地替换。 + +## 前置条件 + +### 硬件 + +Atlas **800T** / **900 A2** 训练系列,芯片为 Ascend **910B**,需支持 bf16。本文示例为单卡。 + +### 软件 + +| 类别 | 要求 | +| --- | --- | +| CANN | toolkit 与驱动固件已安装,并可 `source set_env.sh`,9.1.0 或以上 | +| Python | 3.10 或以上 | +| git | 用于克隆上游仓库 | +| 模型 | [Qwen/Qwen2.5-0.5B](https://huggingface.co/Qwen/Qwen2.5-0.5B)(base 版) | + +### 本文验证环境 + +| 项目 | 内容 | +| --- | --- | +| 机器 | Atlas 900 A2,Ascend 910B,单卡 | +| 镜像 | `swr.cn-south-1.myhuaweicloud.com/ascendhub/cann:9.1.0-910b-ubuntu22.04-py3.12` | +| CANN | 9.1.0 | +| Python | 3.12 | +| torch / torch_npu | 2.9.0 / 2.9.0.post2 | + +阅读本文前,请先按 [快速安装昇腾环境](https://ascend.github.io/docs/sources/ascend/quick_install.html) 准备好 CANN 与驱动。 + +## 1. 加载 CANN 环境 + +新开终端后 CANN 变量不会自动生效。 + +```shell +export PATH=/usr/local/sbin:/usr/local/bin:$PATH +source /usr/local/Ascend/ascend-toolkit/set_env.sh +``` + +## 2. 检查环境是否就绪 + +```shell #test-setup +source /usr/local/Ascend/ascend-toolkit/set_env.sh +test -n "$ASCEND_HOME_PATH" +command -v npu-smi +npu-smi info +python --version +``` + +`npu-smi` 打印出设备列表、`Health` 为 `OK` 即可;若提示找不到 `npu-smi`,回到 [快速安装昇腾环境](https://ascend.github.io/docs/sources/ascend/quick_install.html) 检查驱动与设备挂载。 + +## 3. 获取代码 + +将 `` 替换为上游最新的 `main` 提交。 + +```shell #test-setup store="upstream_ref" +echo "${UPSTREAM_REF}" +``` + +```shell #test-setup id="clone" load="upstream_ref>>ref" +git clone --depth 1 https://github.com/AkaliKong/MiniOneRec.git +cd MiniOneRec +git checkout +``` + +## 4. 安装依赖 + +上游 `requirements.txt` 面向 CUDA 环境(`torchrec`、`fbgemm_gpu` 是 `+cu118` 安装包,另带整套 `nvidia-*-cu12` 运行库),NPU 上改用与 CANN 9.1.0 配套的 torch 栈单独安装。 + +```shell #test-setup id="install" +source /usr/local/Ascend/ascend-toolkit/set_env.sh +pip install torch==2.9.0 torch-npu==2.9.0.post2 +pip install transformers==4.57.1 accelerate==1.10.1 datasets==4.2.0 \ + tokenizers==0.22.1 fire==0.7.1 pandas==2.2.2 tqdm bitsandbytes==0.48.1 \ + trl==0.24.0 scikit-learn==1.5.2 wandb==0.22.2 +``` + +`bitsandbytes` 钉在 aarch64 有预编译包的 0.48.1:`sft.py` 与 `evaluate.py` 顶部的 `import bitsandbytes as bnb` 从未被实际使用,但不改源码就绕不开这行 import,必须让它能导入。`trl` 提供 `rl.py` 用的 `GRPOConfig`,`scikit-learn` 提供 `ndcg_score`,`wandb` 则是 `sasrec.py` 无条件导入的。`sft.py` 的 `TrainingArguments` 默认 `report_to="all"`,装好 wandb 后会尝试 `wandb.init()` 并因缺少 API key 失败,所以第 8 节导出 `WANDB_MODE=disabled`。 + +## 5. 观察默认行为:两个入口的设备走向不一样 + +只 `import torch_npu` 不会改写 `torch.cuda` 这一面,而 MiniOneRec 的两个入口对它的依赖并不相同。`sft.py` 把设备选择交给 `TrainingArguments`,transformers 的设备链里 NPU 分支排在 CUDA 之前,所以能直接拿到 `npu:0`;`evaluate.py` 在模块级就把 `device` 固化成 `"cpu"`。下面这段命令能同时看到两种结果: + +```shell #test id="probe-default" +source /usr/local/Ascend/ascend-toolkit/set_env.sh +cd MiniOneRec +python -c " +import torch, torch_npu +from transformers import TrainingArguments +print('npu available:', torch.npu.is_available()) +print('cuda mapped:', torch.cuda.is_available()) +print('evaluate.py device:', 'cuda' if torch.cuda.is_available() else 'cpu') +print('trainer device:', TrainingArguments(output_dir='/tmp/probe-default').device) +" +``` + +输出结果如下: + +```shell #test-result id="probe-default" +npu available: True +cuda mapped: False +evaluate.py device: cpu +trainer device: npu:0 +``` + +## 6. 为评估与 RL 启用 NPU 映射 + +`torch_npu.contrib.transfer_to_npu` 把 `torch.cuda` 这一整套接口映射到 NPU,导入即生效。为了让映射只作用于需要它的命令,把 `sitecustomize.py` 放进独立目录、通过 `PYTHONPATH` 挂到命令上——不写进 site-packages,其他进程不受影响。 + +```shell #test-setup id="npu-shim" +mkdir -p /tmp/minionerec-npu-shim +cat > /tmp/minionerec-npu-shim/sitecustomize.py <<'INS' +import torch_npu.contrib.transfer_to_npu # noqa: F401 +INS +``` + +确认映射只覆盖挂了 `PYTHONPATH` 的那条命令: + +```shell #test id="check-scoped" +source /usr/local/Ascend/ascend-toolkit/set_env.sh +cd MiniOneRec +python -c "import torch; print('default process:', 'cuda' if torch.cuda.is_available() else 'cpu')" +PYTHONPATH=/tmp/minionerec-npu-shim python -c " +import torch +print('with shim:', 'cuda' if torch.cuda.is_available() else 'cpu') +print('tensor landed on:', torch.zeros(1, device='cuda').device.type) +" +``` + +输出结果如下(代码里写的是 `cuda`,张量实际落在 NPU): + +```shell #test-result id="check-scoped" +default process: cpu +with shim: cuda +tensor landed on: npu +``` + +确认仓库源码没被动过(前面的检查与注入都不落在仓库里): + +```shell #test id="no-source-change" +cd MiniOneRec +test -z "$(git status --porcelain)" && echo "source untouched" +``` + +输出结果如下: + +```shell #test-result id="no-source-change" +source untouched +``` + +## 7. 下载基座模型与预处理数据 + +基座模型取 Qwen2.5 的 **base** 版,Instruct 版会让约束解码失效。模型与预处理数据都在 HuggingFace 上:`Qwen/Qwen2.5-0.5B` 是权重,`kkknight/MiniOneRec` 是官方预处理好的 Amazon Industrial_and_Scientific 数据。数据侧只取 SID 索引、训练 / 验证 / 测试 CSV 与 `info/` 下的 SID-条目清单;`.npy` 嵌入文件只有 RQ/SID 构建阶段需要,跳过。 + +```shell #test-setup id="download" +cd MiniOneRec +python -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='Qwen/Qwen2.5-0.5B', local_dir='./Qwen2.5-0.5B')" +python -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='kkknight/MiniOneRec', allow_patterns=['Amazon/index/Industrial_and_Scientific.index.json','Amazon/index/Industrial_and_Scientific.item.json','Amazon/train/Industrial_and_Scientific_5_2016-10-2018-11.csv','Amazon/valid/Industrial_and_Scientific_5_2016-10-2018-11.csv','Amazon/test/Industrial_and_Scientific_5_2016-10-2018-11.csv','Amazon/info/Industrial_and_Scientific_5_2016-10-2018-11.txt'], local_dir='data')" +``` + +校验资产落位: + +```shell #test id="verify-assets" +cd MiniOneRec +test -f Qwen2.5-0.5B/config.json && echo "base model ok" +test -f data/Amazon/index/Industrial_and_Scientific.index.json && echo "sid index ok" +test -f data/Amazon/index/Industrial_and_Scientific.item.json && echo "item meta ok" +test -f data/Amazon/train/Industrial_and_Scientific_5_2016-10-2018-11.csv && echo "train csv ok" +test -f data/Amazon/valid/Industrial_and_Scientific_5_2016-10-2018-11.csv && echo "valid csv ok" +test -f data/Amazon/info/Industrial_and_Scientific_5_2016-10-2018-11.txt && echo "info txt ok" +``` + +输出结果如下: + +```shell #test-result id="verify-assets" +base model ok +sid index ok +item meta ok +train csv ok +valid csv ok +info txt ok +``` + +## 8. SFT 训练 + +单卡跑 1 epoch:`sample=600` 限制每个子数据集(SID 序列 / SID-条目对齐 / 融合序列推荐)各取 600 条,`cutoff_len=256` 截断序列。完整训练(8 卡 A100、全量数据)见上游 `sft.sh`。 + +```shell #test-setup id="sft-run" +source /usr/local/Ascend/ascend-toolkit/set_env.sh +export ASCEND_RT_VISIBLE_DEVICES=0 +export WANDB_MODE=disabled +cd MiniOneRec +python sft.py \ + --base_model ./Qwen2.5-0.5B \ + --train_file ./data/Amazon/train/Industrial_and_Scientific_5_2016-10-2018-11.csv \ + --eval_file ./data/Amazon/valid/Industrial_and_Scientific_5_2016-10-2018-11.csv \ + --output_dir ./sft_out \ + --batch_size 8 \ + --micro_batch_size 2 \ + --num_epochs 1 \ + --cutoff_len 256 \ + --sample 600 \ + --category Industrial_and_Scientific \ + --sid_index_path ./data/Amazon/index/Industrial_and_Scientific.index.json \ + --item_meta_path ./data/Amazon/index/Industrial_and_Scientific.item.json \ + --freeze_LLM False \ + --train_from_scratch False +``` + +训练完成后校验产物(`model.safetensors` + `config.json` + tokenizer 配置): + +```shell #test id="verify-sft" +cd MiniOneRec +test -f sft_out/final_checkpoint/model.safetensors && echo "model saved" +test -f sft_out/final_checkpoint/config.json && echo "config saved" +test -f sft_out/final_checkpoint/tokenizer_config.json && echo "tokenizer saved" +grep -o '"model_type": "[a-z0-9_.-]*"' sft_out/final_checkpoint/config.json +``` + +输出结果如下: + +```shell #test-result id="verify-sft" +model saved +config saved +tokenizer saved +"model_type": "qwen2" +``` + +## 9. 约束解码评估 + +用上一节得到的 `final_checkpoint` 在 test 切片上做 beam search 评估,再算 HR/NDCG/CC。**CC(无效 SID 生成数)必须为 0**——`ConstrainedLogitsProcessor` 用 trie 约束每步只允许合法 SID 前缀 token,这是约束解码在 NPU 上生效的关键信号。本节用 `num_beams=20`、`batch_size=2`。 + +切出 test 子集: + +```shell #test-setup id="prep-eval" +cd MiniOneRec +head -n 200 data/Amazon/test/Industrial_and_Scientific_5_2016-10-2018-11.csv > test_small.csv +wc -l test_small.csv +``` + +运行评估(进度条与 transformers 警告走 stderr,stdout 只有 category 名): + +```shell #test id="run-eval" +source /usr/local/Ascend/ascend-toolkit/set_env.sh +export ASCEND_RT_VISIBLE_DEVICES=0 +cd MiniOneRec +PYTHONPATH=/tmp/minionerec-npu-shim python evaluate.py \ + --base_model ./sft_out/final_checkpoint \ + --info_file ./data/Amazon/info/Industrial_and_Scientific_5_2016-10-2018-11.txt \ + --test_data_path test_small.csv \ + --result_json_data ./eval_result.json \ + --category Industrial_and_Scientific \ + --num_beams 20 --batch_size 2 +``` + +输出结果如下(进度条走 stderr): + +```shell #test-result id="run-eval" +industrial and scientific items +... +``` + +校验预测产物: + +```shell #test id="verify-eval" +cd MiniOneRec +python -c "import json; d=json.load(open('eval_result.json')); print(len(d)); print('predict' in d[0])" +``` + +输出结果如下: + +```shell #test-result id="verify-eval" +199 +True +``` + +计算 HR/NDCG/CC。HR/NDCG 随训练状态浮动,判断约束解码是否生效看 CC 是否为 0: + +```shell #test id="calc-metrics" +cd MiniOneRec +python calc.py \ + --path ./eval_result.json \ + --item_path ./data/Amazon/info/Industrial_and_Scientific_5_2016-10-2018-11.txt +``` + +输出结果如下: + +```shell #test-result id="calc-metrics" +20 +[1, 3, 5, 10, 20] +NDCG:... +HR... +0 +``` + +## 10. 推荐导向 RL + +RL 在 SFT 产物上再做一轮 GRPO:每个 prompt 采样多条候选、组内归一化奖励、并用 KL 约束贴近参考模型;动作空间是有限的 SID 列表,候选由约束 beam search 生成。 + +上游有三处写死值与单卡运行冲突,用一条 `sed` 就地替换(都是单行,只在本次运行生效): + +- `optim="paged_adamw_32bit"` 走 bitsandbytes,而 bitsandbytes 只提供 CPU、CUDA 等后端,没有 NPU 实现,换成 torch 原生 `adamw_torch`。 +- `sample = -1` 决定 SID 数据集与「标题→SID」数据集的样本数,`-1` 会把整个商品目录展开,训练会拉长到上万步,改为 40。 +- `RLSeqTitle2SidDataset(..., sample=10000)` 是第三个数据集,采样上限写死在调用处,不受上面那个变量影响;训练集切到 40 行后样本池只有 40,`sample(10000)` 会抛 `Cannot take a larger sample than population`,同样改为 40。 + +本节会就地修改 `rl.py` 三行。 + +```shell #test id="rl-patch" +cd MiniOneRec +sed -i -e 's/optim="paged_adamw_32bit"/optim="adamw_torch"/' \ + -e 's/^ sample = -1$/ sample = 40/' \ + -e 's/sample=10000/sample=40/' rl.py +grep -c 'optim="adamw_torch"' rl.py +grep -c '^ sample = 40$' rl.py +grep -c 'sample=40' rl.py +``` + +输出结果如下: + +```shell #test-result id="rl-patch" +1 +1 +1 +``` + +三个数据集都按 40 取样,所以把训练集也切到 40 行(`pandas` 允许取样数等于总体数): + +```shell #test id="prep-rl" +cd MiniOneRec +python - <<'PY' +import pandas as pd + +df = pd.read_csv('data/Amazon/train/Industrial_and_Scientific_5_2016-10-2018-11.csv') +df.head(40).to_csv('data/rl_train_40.csv', index=False) +print('rl train rows:', len(pd.read_csv('data/rl_train_40.csv'))) +PY +``` + +输出结果如下: + +```shell #test-result id="prep-rl" +rl train rows: 40 +``` + +在 0 号卡上跑 1 epoch。`train_batch_size` 必须能被 `num_generations` 整除;`num_generations` 同时是 beam 宽度,本节取 2。 + +```shell #test id="run-rl" +source /usr/local/Ascend/ascend-toolkit/set_env.sh +export ASCEND_RT_VISIBLE_DEVICES=0 +cd MiniOneRec +PYTHONPATH=/tmp/minionerec-npu-shim python rl.py \ + --model_path ./sft_out/final_checkpoint \ + --train_file ./data/rl_train_40.csv \ + --eval_file ./data/Amazon/valid/Industrial_and_Scientific_5_2016-10-2018-11.csv \ + --info_file ./data/Amazon/info/Industrial_and_Scientific_5_2016-10-2018-11.txt \ + --category Industrial_and_Scientific \ + --train_batch_size 4 \ + --eval_batch_size 4 \ + --num_generations 2 \ + --num_train_epochs 1 \ + --gradient_accumulation_steps 1 \ + --learning_rate 1e-5 \ + --beta 1e-3 \ + --beam_search True \ + --reward_type ranking \ + --sync_ref_model True \ + --test_during_training False \ + --eval_step 1.0 \ + --output_dir ./rl_out \ + --sid_index_path ./data/Amazon/index/Industrial_and_Scientific.index.json \ + --item_meta_path ./data/Amazon/index/Industrial_and_Scientific.item.json +``` + +输出结果如下(数据集对象与进度条走 stderr): + +```shell #test-result id="run-rl" +Industrial_and_Scientific +... +``` + +日志里的 `reward`、`reward_std`、`loss`、`grad_norm`、`kl` 会一直是 0,这不是故障。`rule_reward` 要求生成的 SID 与目标完全相等;上一节只训 1 epoch,精确命中率接近 0,组内两条候选都命不中,`ndcg_rule_reward` 还会把整组补 0,GRPO 的组内优势因此无从计算,梯度自然为 0。到这里三个阶段都已跑通;要看到非零的训练信号,需要更充分的 SFT。 + +校验 `rl.py` 结尾的两步落盘(`trainer.save_model` 与 `model.save_pretrained`): + +```shell #test id="verify-rl" +cd MiniOneRec +test -f rl_out/final_checkpoint/model.safetensors && echo "rl model saved" +test -f rl_out/final_checkpoint/config.json && echo "rl config saved" +test -f rl_out/final_checkpoint/tokenizer_config.json && echo "rl tokenizer saved" +``` + +输出结果如下: + +```shell #test-result id="verify-rl" +rl model saved +rl config saved +rl tokenizer saved +``` + +## 相关链接 + +- 上游仓库:[AkaliKong/MiniOneRec](https://github.com/AkaliKong/MiniOneRec) +- 基座模型:[Qwen2.5-0.5B](https://huggingface.co/Qwen/Qwen2.5-0.5B) +- 预处理数据与 SID 索引:[kkknight/MiniOneRec](https://huggingface.co/kkknight/MiniOneRec) +- 昇腾 PyTorch 安装与兼容性:[Ascend/pytorch](https://gitcode.com/Ascend/pytorch) diff --git a/tests/minionerec/__init__.py b/tests/minionerec/__init__.py new file mode 100644 index 000000000..a978283fe --- /dev/null +++ b/tests/minionerec/__init__.py @@ -0,0 +1,29 @@ +"""Tests package marker. + +Single responsibility: inject the repo's ``tests/`` into ``sys.path`` +so that ``from doc_test.base import ...`` can resolve. + +Framework deps (mistune) are installed by the common quick-start workflow +template, not at import time here. + +Why it lives here: +* unittest treats ``tests/`` as a package; the parent ``__init__.py`` + executes before any submodule import. +* It runs before ``tests/test_*.py`` import, which is the earliest + opportunity to inject ``sys.path``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# sys.path bootstrap: make ``doc_test.*`` resolvable. +# Layout: tests/minionerec/__init__.py -> parents[0]=tests/minionerec, +# parents[1]=tests, parents[2]=repo root. +_REPO_ROOT = Path(__file__).resolve().parents[2] +_TESTS_ROOT = _REPO_ROOT / 'tests' +for _p in (_TESTS_ROOT, _REPO_ROOT): + _ps = str(_p) + if _ps not in sys.path: + sys.path.insert(0, _ps) diff --git a/tests/minionerec/test_quick_start_ascend.py b/tests/minionerec/test_quick_start_ascend.py new file mode 100644 index 000000000..28788d072 --- /dev/null +++ b/tests/minionerec/test_quick_start_ascend.py @@ -0,0 +1,180 @@ +"""Quick-start test: doc under test is ``sources/minionerec/quick_start.md``. + +End-to-end case built on top of the ``MarkdownDocTestBase`` contract: +clone ``AkaliKong/MiniOneRec`` -> install the NPU torch stack plus the +SFT / evaluation / RL dependency set -> probe the default device +behaviour -> install a ``PYTHONPATH``-scoped ``sitecustomize.py`` that +imports ``torch_npu.contrib.transfer_to_npu`` -> download Qwen2.5-0.5B +and the preprocessed Amazon Industrial_and_Scientific subset from +HuggingFace -> one SFT epoch -> constrained-decoding evaluation (CC must +be 0) -> one GRPO epoch after patching three hard-coded values in +``rl.py``. + +Why the patch: ``rl.py`` pins ``optim="paged_adamw_32bit"`` (bitsandbytes +ships no NPU backend), ``sample = -1`` (expands the whole item catalogue) +and ``sample=10000`` at the ``RLSeqTitle2SidDataset`` call site (a +``DataFrame.sample`` call that raises once the train CSV is sliced). +``sft.py`` and ``evaluate.py`` stay byte-identical, asserted by the doc's +``git status --porcelain`` check. + +At this scale every reward stays 0: ``rule_reward`` is exact match, one +SFT epoch never hits, and ``ndcg_rule_reward`` zeroes the whole group +when nothing hits - so the group advantage and the gradient are 0 too. +The RL stage therefore guards flow-through only: ``run-rl`` must exit 0 +and ``rl.py``'s closing ``save_model`` / ``model.save_pretrained`` must +leave weights, config and tokenizer under ``rl_out/final_checkpoint``. + +Run: ``python -m unittest tests.minionerec.test_quick_start_ascend -v 2>&1`` + +Environment variables (injected by the engine +``quick-start-template.yml``, triggered by ``minionerec-quick-start.yml``): + ``MONITORED_DOC_URL`` doc to fetch and execute + ``UPSTREAM_REF`` ref substituted into the doc's ```` + ``NPU_READY=true`` Required, otherwise the class is skipped. + End-to-end tests only run on the NPU + runner: local dev machines / normal ubuntu + runners have no ``/dev/davinci*`` device. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import unittest + +from doc_test.base import MarkdownDocTestBase + + +def _is_truthy(value: str | None) -> bool: + """``'true'`` -> True (case-insensitive); anything else (unset) -> False.""" + if not value: + return False + return value.strip().lower() == 'true' + + +def _e2e_enabled() -> bool: + """Return True when ``NPU_READY=true`` is set, releasing the skip.""" + return _is_truthy(os.environ.get('NPU_READY')) + + +class TestQuickStartAscend(MarkdownDocTestBase, unittest.TestCase): + """``sources/minionerec/quick_start.md`` end-to-end test: fetch doc -> + validate contract -> run ``#test-setup`` / ``#test`` in order -> + compare against ``#test-result``. + + Working directory is pinned to ``/root/minionerec-test``: the doc is + cwd-relative (clones to ``./MiniOneRec``, downloads into + ``./MiniOneRec/data``), so the clone tree must not collide with the + ``workflows`` checkout the engine runs the test from. + """ + + # SFT (~11 min) and RL (~14 min) dominate; 1h per command leaves + # headroom for a cold HuggingFace download. + DEFAULT_COMMAND_TIMEOUT = 3600 + + USER_AGENT = 'ascend-docs/quick-start' + + # Extend the base ERROR_MARKERS with CANN's typo + sentinel so a + # CANN runtime failure surfaces a full stderr dump instead of a + # head/tail window that hides the failing line. + ERROR_MARKERS = ( + *MarkdownDocTestBase.ERROR_MARKERS, + 'applicaiton exception', # CANN toolkit emits this typo (sic) + 'ERR99999', # CANN sentinel for unrecoverable runtime failure + ) + + _CANN_SET_ENV_CANDIDATES = ( + '/usr/local/Ascend/ascend-toolkit/set_env.sh', + '/usr/local/Ascend/cann/set_env.sh', + ) + + _WORK_DIR = '/root/minionerec-test' + + # ---------------------------------------------------------- + # prepare_environment: PATH + CANN env + cwd + # ---------------------------------------------------------- + + @classmethod + def prepare_environment(cls) -> None: + """CANN env, ``git`` and the working directory. + + Everything business-facing is in the document: the dependency + install, the shim, the model and data download, SFT, evaluation + and RL are the steps a reader follows. + """ + # 0) PATH: /usr/local/sbin for npu-smi. + current_path = os.environ.get('PATH', '') + prefix_dirs = '/usr/local/sbin:/usr/local/bin' + if not current_path.startswith(prefix_dirs): + os.environ['PATH'] = f'{prefix_dirs}:{current_path}' + + # 1) CANN env: source set_env.sh and merge the env stream into + # os.environ so every doc block's subprocess inherits + # LD_LIBRARY_PATH / ASCEND_* without re-sourcing. + sourced = False + for set_env in cls._CANN_SET_ENV_CANDIDATES: + if os.path.isfile(set_env): + merged = subprocess.run( + ['bash', '-c', f'source {set_env} >/dev/null 2>&1; env'], + capture_output=True, text=True, check=True, + ) + for line in merged.stdout.splitlines(): + if '=' not in line: + continue + key, _, value = line.partition('=') + # Don't overwrite what the workflow injected. + os.environ.setdefault(key, value) + print(f'setup: sourced CANN env from {set_env}') + sourced = True + break + if not sourced: + print( + 'setup: no CANN set_env.sh found (expected ' + f'{cls._CANN_SET_ENV_CANDIDATES}) - every step will fail' + ) + + # 2) git: the engine bootstraps it, but a missing git turns the + # clone into an opaque `command not found`. + if shutil.which('git') is None: + print('setup: installing git') + subprocess.run(['apt-get', 'update', '-qq'], check=True) + subprocess.run(['apt-get', 'install', '-y', '-qq', 'git'], check=True) + + # 3) Working directory: pinned before run_template() so the doc's + # cwd-relative clone never lands in the guard checkout. + os.makedirs(cls._WORK_DIR, exist_ok=True) + os.chdir(cls._WORK_DIR) + print(f'setup: working dir {cls._WORK_DIR}') + + # ---------------------------------------------------------- + # test entry + # ---------------------------------------------------------- + + @classmethod + def setUpClass(cls) -> None: + """Run env setup once per test class. + + ``@unittest.skipIf`` only skips the test *method* — ``setUpClass`` + itself always runs. The ``if _e2e_enabled()`` body guard below is + what actually keeps heavy setup from firing when ``NPU_READY`` is + unset. + """ + if _e2e_enabled(): + cls.prepare_environment() + + @unittest.skipIf( + not _e2e_enabled(), + 'end-to-end requires NPU runner; set NPU_READY=true', + ) + def test_runs_doc(self) -> None: + """Template-method entry point. The base class + ``run_template()`` runs the full ``pre_process`` -> ``parse`` -> + ``execute`` -> ``post_process`` flow. ``prepare_environment`` is + triggered by ``setUpClass`` once, not from ``run_template``.""" + + self.run_template() + + +if __name__ == '__main__': + unittest.main()