教程中会用到大量第三方库。国内网络环境下,推荐直接使用清华 PyPI 镜像安装,速度快且稳定。
# ① 临时使用清华镜像安装(单次安装加 -i 参数即可)
pip install numpy pandas -i https://pypi.tuna.tsinghua.edu.cn/simple
# ② 永久配置清华镜像(配置一次,之后所有 pip install 自动走镜像,推荐)
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
# ③ 升级 pip 自身
python -m pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple
# ④ 项目A(大语言模型微调)依赖
pip install langchain langchain-community langchain-ollama transformers peft bitsandbytes trl datasets accelerate
# ⑤ 项目B(目标检测)依赖
pip install ultralytics albumentations scikit-learn matplotlib onnxruntime onnx labelme
清华:https://pypi.tuna.tsinghua.edu.cn/simple 阿里云:https://mirrors.aliyun.com/pypi/simple/ 中科大:https://mirrors.ustc.edu.cn/pypi/simple。恢复官方源用 pip config unset global.index-url。
本教程面向已经会 Python、希望真正理解人工智能项目“怎么操作、怎么写代码、怎么排错”的学习者。
模块 A:VS Code + SSH + Ubuntu + Qwen3 7B + QLoRA + GGUF/INT4
模块 B:PyCharm + Windows + LabelMe + 目标检测 + 指标评估 + ONNX
教程中的 IP、用户名、模型名、路径、类别数量、输入尺寸等全部采用假设值。实际环境发生变化时,优先修改每节标出的“需要修改的位置”,不要直接大面积改动代码。
人工智能实操不是“写一个 Python 文件”这么简单,而是一条完整流水线。
原始 TXT
↓
读取文本
↓
文本分块 chunk
↓
Ollama + Qwen3 7B
↓
生成 instruction / input / output
↓
dataset.json
↓
Qwen3 7B 基础模型
↓
4bit + LoRA
↓
QLoRA 微调
↓
LoRA 合并
↓
完整模型
↓
GGUF
↓
INT4
↓
本地自检
↓
最终推理脚本
图片/视频
↓
数据筛选
↓
数据清洗
↓
LabelMe 标注
↓
标签格式转换
↓
数据增强
↓
训练集 / 验证集
↓
目标检测模型
↓
训练
↓
训练日志
↓
验证
↓
mAP / Precision / Recall / F1
↓
混淆矩阵 / ROC / PR / AUC
↓
量化
↓
ONNX
↓
ONNX Runtime 部署
| 项目 | 假设值 |
|---|---|
| 本地系统 | Windows 11 |
| 编辑器 | VS Code |
| 远程系统 | Ubuntu 22.04 |
| SSH IP | 192.168.1.100 |
| SSH 端口 | 22 |
| 用户名 | user |
| 远程工作目录 | /home/user/workspace |
| 基础模型目录 | /home/user/workspace/QwenPretrain |
| Ollama 模型 | qwen3:7b |
| Ollama 地址 | http://127.0.0.1:11434 |
| 数据目录 | /home/user/workspace/data |
| 项目目录 | /home/user/workspace/model_b |
环境发生变化时,重点修改:
SSH 登录命令
DATA_DIR
BASE_MODEL
MODEL_PATH
DATASET_FILE
OUTPUT_DIR
OLLAMA_MODEL
OLLAMA_BASE_URL
可以简单理解为:
Windows 电脑
↓ SSH
Ubuntu 远程机器
你在 VS Code 里写代码,但是 Python 实际运行在 Ubuntu 上。
因此:
VS Code = 编码界面
SSH = 远程连接通道
Ubuntu = 实际运行代码和模型的机器
在 VS Code:
Ctrl + Shift + P
搜索:
Remote-SSH: Connect to Host
添加:
ssh user@192.168.1.100
如果端口不是 22:
ssh user@192.168.1.100 -p 2222
例如实际信息是:
IP = 10.10.10.25
用户名 = aiuser
端口 = 2222
修改为:
ssh aiuser@10.10.10.25 -p 2222
不要一连接成功就开始写模型代码,先确认环境真的进入了远程机器。
打开:
Terminal → New Terminal
依次执行:
whoami
查看用户名。
hostname
查看主机名。
pwd
查看当前目录。
ls
查看文件。
ls -lh
查看文件大小和权限。
ls -lh /home/user/workspace
确认工作目录。
cd /home/user/workspace
进入目录。
cd ..
返回上一层。
cd ~
回到当前用户主目录。
mkdir model_b
多级目录:
mkdir -p /home/user/workspace/model_b/data
ls -lh
cat test.txt
less test.txt
cp a.txt b.txt
cp -r data backup
mv a.txt data/
rm a.txt
rm -rf test
rm -rf 删除前一定确认当前路径。
执行:
mkdir -p /home/user/workspace/model_b/data
mkdir -p /home/user/workspace/model_b/quantization
mkdir -p /home/user/workspace/model_b/submission
mkdir -p /home/user/workspace/model_b/checkpoint-best
结构:
/home/user/workspace/
│
├── data/
│ ├── 美国次贷危机.txt
│ ├── 尿毒性心包炎.txt
│ └── 生成式人工智能服务管理暂行办法.txt
│
├── QwenPretrain/
│
└── model_b/
├── data/
├── quantization/
├── submission/
└── checkpoint-best/
这里的三个文本主题是教程示例;实际文件名不同,只修改 Python 中的 FILES。
先不接模型,第一步只验证文件读取。
创建:
generate_dataset.py
from pathlib import Path
# ============================================================
# 需要根据实际环境修改
# ============================================================
DATA_DIR = Path(
"/home/user/workspace/data"
)
FILES = [
"美国次贷危机.txt",
"尿毒性心包炎.txt",
"生成式人工智能服务管理暂行办法.txt",
]
def read_txt(file_path: Path) -> str:
"""读取 UTF-8 文本。"""
with open(
file_path,
"r",
encoding="utf-8"
) as f:
return f.read()
def main():
for filename in FILES:
file_path = DATA_DIR / filename
print("=" * 60)
print("正在读取:", file_path)
text = read_txt(file_path)
print("文件长度:", len(text))
print("前500个字符:")
print(text[:500])
if __name__ == "__main__":
main()
运行:
cd /home/user/workspace/model_b
python generate_dataset.py
Path(...)
创建一个路径对象。
open(...)
打开文本。
read()
把全部内容读成字符串。
text[:500]
取前 500 个字符。
例如:
FileNotFoundError:
No such file or directory
第一反应不是改 Python,而是:
ls -lh /home/user/workspace/data
检查真实文件名。
如果真实目录是:
/home/user/workspace/dataset
只修改:
DATA_DIR = Path(
"/home/user/workspace/dataset"
)
假设一篇文本有几万字,不能简单地一次全部交给模型。
本教程假设:
chunk_size = 512
def split_text(
text: str,
chunk_size: int = 512
):
chunks = []
for start in range(
0,
len(text),
chunk_size
):
chunk = text[
start:start + chunk_size
]
if chunk.strip():
chunks.append(
chunk.strip()
)
return chunks
text[0:512]
代表第 0 到 511 个字符。
下一块:
text[512:1024]
所以:
原文本
↓
0~511
512~1023
1024~1535
...
把:
CHUNK_SIZE = 512
改成:
CHUNK_SIZE = 400
即可。
如果未来要求严格按照 tokenizer token 数量切分,则不能继续直接使用字符串切片,需要改成:
文本
↓
tokenizer.encode()
↓
按 token 数切片
这属于算法实现变化,而不是简单改参数。
先在 Ubuntu 终端验证:
ollama list
确认有:
qwen3:7b
再运行:
ollama run qwen3:7b
输入:
你是谁?
有正常返回后再用 Python。
from langchain_ollama import ChatOllama
llm = ChatOllama(
model="qwen3:7b",
base_url="http://127.0.0.1:11434",
temperature=0.2,
)
model
使用哪个 Ollama 模型
base_url
Ollama 服务地址
temperature
输出随机性
原来:
model="qwen3:7b"
实际为:
model="qwen3:14b"
只改模型名。
原来:
base_url="http://127.0.0.1:11434"
实际在:
10.10.10.20:11434
修改:
base_url="http://10.10.10.20:11434"
创建:
test_ollama.py
from langchain_ollama import ChatOllama
llm = ChatOllama(
model="qwen3:7b",
base_url="http://127.0.0.1:11434",
temperature=0.2,
)
question = "什么是金融危机?"
response = llm.invoke(
question
)
print(response.content)
运行:
python test_ollama.py
这一刻打通了:
VS Code
↓
SSH
↓
Ubuntu
↓
Python
↓
LangChain
↓
Ollama
↓
Qwen3
我们需要让模型把普通文本转成:
{
"instruction": "问题",
"input": "",
"output": "答案"
}
Prompt 至少说明三件事:
角色
任务
输出格式
示例:
def build_prompt(text: str) -> str:
return f"""
你是一个擅长总结和出题的AI助手。
请根据下面提供的专业文本内容,
生成一个具体的问题,并给出准确、完整、简洁的标准答案。
要求:
1. 问题必须能够由给定文本直接或合理推导得到。
2. 答案必须基于提供的文本内容。
3. 不得编造文本中不存在的事实。
4. 严格输出 JSON。
5. 不要输出 Markdown。
6. 不要输出 ```json。
7. JSON 必须包含 instruction、input、output 三个字段。
8. input 固定为空字符串。
输出格式:
{{
"instruction": "问题",
"input": "",
"output": "标准答案"
}}
文本:
{text}
"""
注意:因为这里使用 f-string,JSON 外层花括号需要写成 {{ 和 }}。
理想情况:
{
"instruction": "什么是次级抵押贷款?",
"input": "",
"output": "次级抵押贷款是……"
}
实际模型有时返回:
```json
{
...
}
所以需要清洗。
```python
import json
import re
def extract_json(text: str):
text = text.strip()
text = re.sub(
r"^```json",
"",
text
)
text = re.sub(
r"^```",
"",
text
)
text = re.sub(
r"```$",
"",
text
)
text = text.strip()
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1:
return None
try:
return json.loads(
text[start:end + 1]
)
except json.JSONDecodeError:
return None
def validate_item(item):
if not isinstance(
item,
dict
):
return False
for key in [
"instruction",
"input",
"output"
]:
if key not in item:
return False
if not item["instruction"].strip():
return False
if not item["output"].strip():
return False
return True
正式 JSON 建议最终只保留:
instruction
input
output
生成过程中的领域标记、调试信息可以另外保存,不应混入正式训练数据。
把前面的内容整合成:
generate_dataset.py
import json
import re
from collections import Counter
from pathlib import Path
from langchain_ollama import ChatOllama
# ============================================================
# 需要根据实际环境修改的位置
# ============================================================
DATA_DIR = Path(
"/home/user/workspace/data"
)
OUTPUT_FILE = Path(
"/home/user/workspace/model_b/data/dataset.json"
)
OLLAMA_MODEL = "qwen3:7b"
OLLAMA_BASE_URL = (
"http://127.0.0.1:11434"
)
CHUNK_SIZE = 512
FILES = {
"金融": "美国次贷危机.txt",
"医疗": "尿毒性心包炎.txt",
"法规": "生成式人工智能服务管理暂行办法.txt",
}
llm = ChatOllama(
model=OLLAMA_MODEL,
base_url=OLLAMA_BASE_URL,
temperature=0.2,
)
def read_txt(file_path: Path) -> str:
with open(
file_path,
"r",
encoding="utf-8"
) as f:
return f.read()
def split_text(
text: str,
chunk_size: int = 512
):
chunks = []
for start in range(
0,
len(text),
chunk_size
):
chunk = text[
start:start + chunk_size
]
if chunk.strip():
chunks.append(
chunk.strip()
)
return chunks
def build_prompt(
text: str
) -> str:
return f"""
你是一个擅长总结和出题的AI助手。
请根据下面提供的专业文本内容,
生成一个具体的问题,并给出准确、完整、简洁的标准答案。
要求:
1. 问题必须能够由给定文本直接或合理推导得到。
2. 答案必须基于提供的文本内容。
3. 不得编造文本中不存在的事实。
4. 严格输出 JSON。
5. 不要输出 Markdown。
6. 不要输出 ```json。
7. JSON 必须包含 instruction、input、output 三个字段。
8. input 固定为空字符串。
输出格式:
{{
"instruction": "问题",
"input": "",
"output": "标准答案"
}}
文本:
{text}
"""
def extract_json(text: str):
text = text.strip()
text = re.sub(r"^```json", "", text)
text = re.sub(r"^```", "", text)
text = re.sub(r"```$", "", text)
text = text.strip()
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1:
return None
try:
return json.loads(
text[start:end + 1]
)
except json.JSONDecodeError:
return None
def validate_item(item):
if not isinstance(item, dict):
return False
for key in [
"instruction",
"input",
"output"
]:
if key not in item:
return False
if not item["instruction"].strip():
return False
if not item["output"].strip():
return False
return True
def generate_one(chunk: str):
prompt = build_prompt(chunk)
response = llm.invoke(prompt)
item = extract_json(
response.content
)
if item is None:
return None
if not validate_item(item):
return None
item["input"] = ""
return item
def main():
dataset = []
seen_questions = set()
category_counter = Counter()
for category, filename in FILES.items():
print("\n" + "=" * 70)
print("当前领域:", category)
print("当前文件:", filename)
print("=" * 70)
file_path = DATA_DIR / filename
text = read_txt(file_path)
chunks = split_text(
text,
CHUNK_SIZE
)
print("原始文本长度:", len(text))
print("chunk 数量:", len(chunks))
for index, chunk in enumerate(chunks):
print(
f"[{category}] "
f"{index + 1}/{len(chunks)}"
)
item = None
for attempt in range(3):
try:
item = generate_one(chunk)
except Exception as e:
print("模型调用异常:", e)
item = None
if item is not None:
break
print(
f"第 {attempt + 1} 次生成失败"
)
if item is None:
print("跳过当前 chunk")
continue
question = item["instruction"].strip()
if question in seen_questions:
print("重复问题,跳过")
continue
seen_questions.add(question)
dataset.append(item)
category_counter[category] += 1
OUTPUT_FILE.parent.mkdir(
parents=True,
exist_ok=True
)
with open(
OUTPUT_FILE,
"w",
encoding="utf-8"
) as f:
json.dump(
dataset,
f,
ensure_ascii=False,
indent=2
)
print("\n" + "=" * 70)
print("数据集生成完成")
print("总数量:", len(dataset))
print("=" * 70)
for category in FILES:
print(
category,
":",
category_counter[category]
)
print("文件:", OUTPUT_FILE)
if __name__ == "__main__":
main()
假设:
一个 chunk → 一个样本
那么 chunk 数量不足时,数据自然不足。
例如:
金融:52
医疗:58
法规:61
总数只有:
171
修改:
CHUNK_SIZE = 400
此时返回值从:
dict
变为:
list[dict]
程序结构也要相应修改。
可以让同一个 chunk 分别生成:
事实类问题
原因类问题
影响类问题
概念解释类问题
但必须去重和人工抽查,不能只是复制同一个问题。
可以把两类工具分开理解:
SSH
=
远程控制 + 执行命令
FTP/SFTP
=
传输文件
假设连接信息:
Host:192.168.1.100
Port:22
Username:user
Password:******
打开文件传输工具后填写实际参数。
本地找到:
model_b/data/dataset.json
远程上传到:
/home/user/workspace/model_b/data/
再用 SSH 检查:
ls -lh /home/user/workspace/model_b/data
看到 dataset.json 才算真正完成。
假设:
/home/user/workspace/QwenPretrain
创建:
inference.py
import torch
from transformers import (
AutoTokenizer,
AutoModelForCausalLM,
)
# ============================================================
# 需要根据实际环境修改
# ============================================================
MODEL_PATH = (
"/home/user/workspace/QwenPretrain"
)
def main():
print("加载 tokenizer...")
tokenizer = (
AutoTokenizer.from_pretrained(
MODEL_PATH,
trust_remote_code=True
)
)
print("加载模型...")
model = (
AutoModelForCausalLM.from_pretrained(
MODEL_PATH,
torch_dtype="auto",
device_map="auto",
trust_remote_code=True
)
)
model.eval()
question = "你是谁?"
prompt = (
"### 指令:\n"
+ question
+ "\n### 回答:\n"
)
inputs = tokenizer(
prompt,
return_tensors="pt"
)
device = (
next(model.parameters()).device
)
inputs = {
key: value.to(device)
for key, value in inputs.items()
}
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=128,
do_sample=False
)
input_length = (
inputs["input_ids"].shape[1]
)
generated_tokens = outputs[
0,
input_length:
]
answer = tokenizer.decode(
generated_tokens,
skip_special_tokens=True
)
print("\n模型回答:")
print(answer)
if __name__ == "__main__":
main()
运行:
python inference.py
文字
↓
tokenizer
↓
Token IDs
↓
模型
↓
Token IDs
↓
decode
↓
文字
所以:
tokenizer(...)
是文字到数字。
model.generate(...)
是生成新的 token。
tokenizer.decode(...)
是数字回文字。
完整微调:
基础模型
↓
大量参数参与更新
LoRA:
基础模型冻结
+
额外的小矩阵参与训练
QLoRA:
基础模型
↓
4bit 量化
↓
冻结
↓
LoRA
↓
训练 LoRA
这里最需要理解的不是背代码,而是:
基础模型没有被整体重新训练
教程假设模型有:
q_proj
k_proj
v_proj
o_proj
可以先检查:
for name, module in model.named_modules():
if (
"q_proj" in name
or "k_proj" in name
or "v_proj" in name
or "o_proj" in name
):
print(name)
如果一个都没有,那么不能机械使用:
target_modules=[
"q_proj",
"k_proj",
"v_proj",
"o_proj"
]
应该按照实际模型模块名修改。
创建:
qlora.py
import json
from pathlib import Path
import torch
from torch.utils.data import Dataset
from transformers import (
AutoTokenizer,
AutoModelForCausalLM,
BitsAndBytesConfig,
TrainingArguments,
Trainer,
)
from peft import (
LoraConfig,
get_peft_model,
prepare_model_for_kbit_training,
)
# ============================================================
# 需要根据实际环境修改
# ============================================================
BASE_MODEL = (
"/home/user/workspace/QwenPretrain"
)
DATASET_FILE = (
"/home/user/workspace/model_b/data/dataset.json"
)
OUTPUT_DIR = (
"/home/user/workspace/model_b/qlora_output"
)
def build_prompt(item):
return (
"### 指令:\n"
+ item["instruction"]
+ "\n### 回答:\n"
+ item["output"]
)
class InstructionDataset(Dataset):
def __init__(
self,
data,
tokenizer,
max_length=1024
):
self.items = []
for item in data:
text = build_prompt(item)
encoded = tokenizer(
text,
truncation=True,
max_length=max_length,
padding=False
)
self.items.append(encoded)
def __len__(self):
return len(self.items)
def __getitem__(self, index):
return self.items[index]
class CausalLMCollator:
def __init__(self, tokenizer):
self.tokenizer = tokenizer
def __call__(self, features):
batch = self.tokenizer.pad(
features,
padding=True,
return_tensors="pt"
)
labels = (
batch["input_ids"].clone()
)
labels[
batch["attention_mask"] == 0
] = -100
batch["labels"] = labels
return batch
def main():
if not torch.cuda.is_available():
raise RuntimeError(
"本教程假设使用 GPU 环境。"
)
tokenizer = (
AutoTokenizer.from_pretrained(
BASE_MODEL,
trust_remote_code=True
)
)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
if torch.cuda.is_bf16_supported():
compute_dtype = torch.bfloat16
else:
compute_dtype = torch.float16
quant_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=compute_dtype,
bnb_4bit_use_double_quant=True
)
print("加载基础模型...")
model = (
AutoModelForCausalLM.from_pretrained(
BASE_MODEL,
quantization_config=quant_config,
device_map="auto",
trust_remote_code=True
)
)
model.config.use_cache = False
model = (
prepare_model_for_kbit_training(model)
)
lora_config = LoraConfig(
r=8,
lora_alpha=16,
target_modules=[
"q_proj",
"k_proj",
"v_proj",
"o_proj"
],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(
model,
lora_config
)
model.print_trainable_parameters()
with open(
DATASET_FILE,
"r",
encoding="utf-8"
) as f:
data = json.load(f)
train_dataset = InstructionDataset(
data,
tokenizer,
max_length=1024
)
collator = CausalLMCollator(
tokenizer
)
training_args = TrainingArguments(
output_dir=OUTPUT_DIR,
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4,
logging_steps=1,
save_strategy="steps",
save_steps=50,
save_total_limit=2,
gradient_checkpointing=True,
fp16=(compute_dtype == torch.float16),
bf16=(compute_dtype == torch.bfloat16),
report_to="none"
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset,
data_collator=collator
)
print("开始训练...")
trainer.train()
adapter_dir = (
Path(OUTPUT_DIR) / "adapter"
)
adapter_dir.mkdir(
parents=True,
exist_ok=True
)
model.save_pretrained(
adapter_dir
)
tokenizer.save_pretrained(
adapter_dir
)
print(
"Adapter 保存到:",
adapter_dir
)
if __name__ == "__main__":
main()
r=8
LoRA 的低秩维度。
lora_alpha=16
LoRA 缩放相关参数。
learning_rate=2e-4
学习率。
num_train_epochs=3
完整数据集循环次数。
per_device_train_batch_size=4
每张 GPU 一次处理 4 条样本。
gradient_accumulation_steps=4
累积 4 个小 batch 后再做一次参数更新。
教程中的这些数值是假设值;实际显存和模型结构变化时可以调整。
如果看到:
CUDA out of memory
表示显存不足。
优先修改:
per_device_train_batch_size=4
改成:
per_device_train_batch_size=2
仍然不够:
per_device_train_batch_size=1
再看:
max_length=1024
可降低到:
max_length=768
或:
max_length=512
如果减小 batch,可以通过增加:
gradient_accumulation_steps
来维持较大的有效 batch。
大致关系:
有效 batch
≈
per_device_train_batch_size
×
gradient_accumulation_steps
训练得到的是:
Base Model
+
LoRA Adapter
部署更方便的形式是:
完整模型
创建:
merge_lora.py
import torch
from transformers import (
AutoTokenizer,
AutoModelForCausalLM,
)
from peft import PeftModel
# ============================================================
# 需要根据实际环境修改
# ============================================================
BASE_MODEL = (
"/home/user/workspace/QwenPretrain"
)
ADAPTER_PATH = (
"/home/user/workspace/model_b/"
"qlora_output/adapter"
)
OUTPUT_PATH = (
"/home/user/workspace/model_b/"
"checkpoint-best"
)
def main():
base_model = (
AutoModelForCausalLM.from_pretrained(
BASE_MODEL,
torch_dtype=torch.float16,
device_map="auto",
trust_remote_code=True
)
)
model = PeftModel.from_pretrained(
base_model,
ADAPTER_PATH
)
merged_model = (
model.merge_and_unload()
)
merged_model.save_pretrained(
OUTPUT_PATH,
safe_serialization=True
)
tokenizer = (
AutoTokenizer.from_pretrained(
ADAPTER_PATH,
trust_remote_code=True
)
)
tokenizer.save_pretrained(
OUTPUT_PATH
)
print(
"完整模型保存:",
OUTPUT_PATH
)
if __name__ == "__main__":
main()
执行:
python merge_lora.py
检查:
ls -lh /home/user/workspace/model_b/checkpoint-best
确认有:
config.json
模型权重
tokenizer 相关文件
如果只有:
adapter_model.safetensors
说明你保存的是 Adapter,而不是完整合并后的模型。
INT4
=
一种低位宽量化表示
GGUF
=
一种模型文件格式
因此通常是:
完整模型
↓
GGUF
↓
INT4
建立:
quant_config.txt
示例:
量化方案:INT4 + GGUF
选择理由:
INT4 可以降低模型存储空间和推理资源占用,
适合显存和内存有限的本地部署环境。
输出格式:GGUF
量化方式:Q4_0
主要工具:llama.cpp
不同版本的 llama.cpp 可能提供不同名称的转换脚本,因此这里按两种情况理解。
python llama.cpp/convert.py \
/home/user/workspace/model_b/checkpoint-best \
--outfile \
/home/user/workspace/model_b/quantization/base.gguf
然后:
./llama.cpp/quantize \
/home/user/workspace/model_b/quantization/base.gguf \
/home/user/workspace/model_b/quantization/model_int4.gguf \
q4_0
先查看:
ls llama.cpp
例如发现:
convert_hf_to_gguf.py
先:
python llama.cpp/convert_hf_to_gguf.py -h
然后按照当前版本帮助信息修改转换命令。
checkpoint-best
↓
base.gguf
↓
model_int4.gguf
固定测试三个问题:
你是谁?
请简要介绍人工智能。
什么是金融危机?
创建:
self_check.py
import time
from llama_cpp import Llama
MODEL_PATH = (
"/home/user/workspace/model_b/"
"quantization/model_int4.gguf"
)
REPORT_PATH = (
"/home/user/workspace/model_b/"
"quantization/self_check_report.txt"
)
QUESTIONS = [
"你是谁?",
"请简要介绍人工智能。",
"什么是金融危机?",
]
def run_question(llm, question):
start = time.perf_counter()
result = (
llm.create_chat_completion(
messages=[
{
"role": "user",
"content": question
}
],
max_tokens=256,
temperature=0.1
)
)
elapsed = (
time.perf_counter() - start
)
answer = (
result["choices"][0]
["message"]["content"]
)
usage = result.get(
"usage",
{}
)
completion_tokens = usage.get(
"completion_tokens",
0
)
speed = (
completion_tokens / elapsed
if elapsed > 0
else 0.0
)
return answer, speed
def main():
llm = Llama(
model_path=MODEL_PATH,
n_ctx=4096,
verbose=False
)
lines = []
speeds = []
for question in QUESTIONS:
answer, speed = run_question(
llm,
question
)
print("\n问题:", question)
print("回答:", answer)
print(
f"速度:{speed:.2f} token/s"
)
lines.append(
"问题:" + question
)
lines.append(
"回答:" + answer
)
lines.append(
f"速度:{speed:.2f} token/s"
)
lines.append("")
speeds.append(speed)
average_speed = (
sum(speeds) / len(speeds)
)
lines.append(
f"平均推理速度:"
f"{average_speed:.2f} token/s"
)
with open(
REPORT_PATH,
"w",
encoding="utf-8"
) as f:
f.write("\n".join(lines))
if __name__ == "__main__":
main()
如果:
生成 200 token
耗时 10 秒
那么:
200 ÷ 10 = 20 token/s
注意比较原模型与量化模型时,要使用相同的测试问题、生成长度和计时方式,否则速度没有可比性。
把量化模型复制,而不是移动:
cp \
/home/user/workspace/model_b/quantization/model_int4.gguf \
/home/user/workspace/model_b/submission/
最终:
submission/
├── model_int4.gguf
├── inference.py
├── inference.sh
└── README_submission.txt
import argparse
from llama_cpp import Llama
MODEL_PATH = "./model_int4.gguf"
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--input",
type=str,
default=None
)
args = parser.parse_args()
if args.input:
question = args.input
else:
question = input("请输入问题:")
llm = Llama(
model_path=MODEL_PATH,
n_ctx=4096,
verbose=False
)
result = llm.create_chat_completion(
messages=[
{
"role": "user",
"content": question
}
],
max_tokens=512,
temperature=0.1
)
answer = (
result["choices"][0]
["message"]["content"]
)
print(answer)
if __name__ == "__main__":
main()
#!/bin/bash
# 依赖示例:
# pip install llama-cpp-python
# 运行示例:
# bash inference.sh --input "什么是尿毒性心包炎?"
python3 inference.py "$@"
cd /home/user/workspace/model_b/submission
bash inference.sh --input "什么是金融危机?"
标准输入测试:
echo "什么是人工智能?" | python3 inference.py
/home/user/workspace/model_b/
│
├── data/
│ └── dataset.json
│
├── inference.py
├── qlora.py
├── merge_lora.py
│
├── checkpoint-best/
│ ├── config.json
│ ├── *.safetensors
│ └── tokenizer.*
│
├── quantization/
│ ├── quant_config.txt
│ ├── base.gguf
│ ├── model_int4.gguf
│ └── self_check_report.txt
│
└── submission/
├── model_int4.gguf
├── inference.py
├── inference.sh
└── README_submission.txt
| 报错 | 优先检查 |
|---|---|
FileNotFoundError |
路径、文件名 |
ModuleNotFoundError |
Python 库/解释器 |
JSONDecodeError |
模型返回是否是真正 JSON |
KeyError |
JSON 是否缺字段 |
CUDA out of memory |
batch、max_length、显存 |
Connection refused |
Ollama 地址、端口、服务状态 |
from_pretrained 报错 |
模型目录结构 |
| GGUF 转换失败 | llama.cpp 版本、模型支持 |
| SSH 连不上 | IP、端口、用户名、密码 |
模块 B 假设全部在 Windows 本地完成。
| 项目 | 假设值 |
|---|---|
| 操作系统 | Windows 11 |
| IDE | PyCharm |
| 项目根目录 | D:\AI_Project\traffic_sign |
| 预训练模型 | traffic_sign_pretrained.pt |
| 类别数 | 4 |
| 图像尺寸 | 640×640 |
| 检测框 IoU 匹配阈值 | 0.5 |
这里明确假设使用 YOLO/Ultralytics 风格的 .pt 模型。如果实际模型属于其他框架,主要替换 train.py、模型验证和 ONNX 导出部分;数据清洗、LabelMe、标签逻辑仍然适用。
打开 PyCharm:
Open
↓
D:\AI_Project\traffic_sign
项目目录:
traffic_sign/
│
├── raw/
├── clean/
├── labelme/
│
├── dataset/
│ ├── images/
│ │ ├── train/
│ │ └── val/
│ ├── labels/
│ │ ├── train/
│ │ └── val/
│ └── data.yaml
│
├── models/
│ └── traffic_sign_pretrained.pt
│
├── task2/
├── task3/
└── task4/
运行 Python 文件:
右键脚本
↓
Run
需要命令行参数:
Run
→ Edit Configurations
→ Parameters
清洗目标:
删除重复图片
删除损坏图片
删除明显不合格图片
统一文件命名
保证图片和标签可对应
创建:
clean_dataset.py
import hashlib
from pathlib import Path
# ============================================================
# 需要根据实际环境修改
# ============================================================
RAW_DIR = Path(
r"D:\AI_Project\traffic_sign\raw"
)
CLEAN_DIR = Path(
r"D:\AI_Project\traffic_sign\clean"
)
VALID_SUFFIXES = {
".jpg",
".jpeg",
".png",
".bmp"
}
def md5_file(path):
md5 = hashlib.md5()
with open(
path,
"rb"
) as f:
while True:
data = f.read(
1024 * 1024
)
if not data:
break
md5.update(data)
return md5.hexdigest()
def main():
CLEAN_DIR.mkdir(
parents=True,
exist_ok=True
)
hashes = {}
for image_path in RAW_DIR.rglob("*"):
if not image_path.is_file():
continue
if image_path.suffix.lower() not in VALID_SUFFIXES:
continue
file_hash = md5_file(
image_path
)
if file_hash in hashes:
print("发现重复图片:")
print("当前:", image_path)
print("重复:", hashes[file_hash])
continue
hashes[file_hash] = image_path
output_path = (
CLEAN_DIR / image_path.name
)
output_path.write_bytes(
image_path.read_bytes()
)
print(
"唯一图片数量:",
len(hashes)
)
if __name__ == "__main__":
main()
from PIL import Image
def check_image(path):
try:
with Image.open(path) as img:
img.verify()
return True
except Exception:
return False
使用:
if not check_image(image_path):
print("损坏图片:", image_path)
continue
可从三层判断:
文件级
↓
文件损坏吗?
图像级
↓
是否极度模糊、过曝、欠曝?
业务级
↓
目标是不是根本无法辨认?
不要把某一个分辨率数字当成绝对标准。本教程中的质量判断阈值只是项目假设。
打开 LabelMe 后:
打开图片
↓
Create Rectangle
↓
框住交通标志
↓
输入类别
↓
保存
假设类别:
speed_limit
no_entry
stop
turn_left
保存后通常有:
xxx.jpg
xxx.json
1. 框尽量贴近目标
2. 类别名称统一
3. 不漏掉明显目标
4. 相同类别不要出现多种拼写
例如下面四个字符串不应该同时存在:
speed_limit
Speed_Limit
speed-limit
speed limit
统一:
speed_limit
classes.txt:
speed_limit
no_entry
stop
turn_left
编号就是:
0 speed_limit
1 no_entry
2 stop
3 turn_left
建议在整个项目中只维护一个类别表,不要在多个 Python 文件中手工重复编号。
典型结构:
{
"shapes": [
{
"label": "speed_limit",
"points": [
[100, 120],
[220, 260]
]
}
]
}
真正有用的信息主要是:
label
points
即:
类别
+
边界框坐标
假设:
图片宽:1000
图片高:800
xmin:100
ymin:120
xmax:220
ymax:260
先计算:
x_center = (xmin + xmax) / 2
= 160
y_center = (ymin + ymax) / 2
= 190
width = xmax - xmin
= 120
height = ymax - ymin
= 140
再归一化:
x_center = 160 / 1000 = 0.16
y_center = 190 / 800 = 0.2375
width = 120 / 1000 = 0.12
height = 140 / 800 = 0.175
最终:
0 0.160000 0.237500 0.120000 0.175000
创建:
convert_labelme.py
import json
from pathlib import Path
from PIL import Image
# ============================================================
# 需要根据实际环境修改
# ============================================================
LABELME_DIR = Path(
r"D:\AI_Project\traffic_sign\labelme"
)
YOLO_LABEL_DIR = Path(
r"D:\AI_Project\traffic_sign\dataset\labels"
)
CLASS_NAMES = [
"speed_limit",
"no_entry",
"stop",
"turn_left",
]
CLASS_TO_ID = {
name: index
for index, name in enumerate(CLASS_NAMES)
}
def shape_to_yolo(
points,
image_width,
image_height,
class_id
):
xs = [
point[0]
for point in points
]
ys = [
point[1]
for point in points
]
xmin = min(xs)
xmax = max(xs)
ymin = min(ys)
ymax = max(ys)
x_center = (
xmin + xmax
) / 2
y_center = (
ymin + ymax
) / 2
width = xmax - xmin
height = ymax - ymin
x_center /= image_width
y_center /= image_height
width /= image_width
height /= image_height
return (
f"{class_id} "
f"{x_center:.6f} "
f"{y_center:.6f} "
f"{width:.6f} "
f"{height:.6f}"
)
def main():
YOLO_LABEL_DIR.mkdir(
parents=True,
exist_ok=True
)
for json_path in LABELME_DIR.glob("*.json"):
with open(
json_path,
"r",
encoding="utf-8"
) as f:
data = json.load(f)
image_path = (
LABELME_DIR
/ data["imagePath"]
)
with Image.open(
image_path
) as img:
width, height = img.size
lines = []
for shape in data["shapes"]:
label = shape["label"]
if label not in CLASS_TO_ID:
print(
"未知类别:",
label,
"文件:",
json_path
)
continue
line = shape_to_yolo(
shape["points"],
width,
height,
CLASS_TO_ID[label]
)
lines.append(line)
txt_path = (
YOLO_LABEL_DIR
/ f"{json_path.stem}.txt"
)
txt_path.write_text(
"\n".join(lines),
encoding="utf-8"
)
print(
"转换完成:",
txt_path
)
if __name__ == "__main__":
main()
正确:
a001.jpg
a001.txt
错误:
a001.jpg
a002.txt
核心就是:
image_path.stem == label_path.stem
dataset/
├── images/
│ ├── train/
│ └── val/
│
└── labels/
├── train/
└── val/
对应关系:
images/train/a001.jpg
labels/train/a001.txt
images/val/b001.jpg
labels/val/b001.txt
目标检测增强必须同时改变:
图片
+
bounding box
常见增强:
亮度变化
对比度变化
缩放
轻微旋转
裁剪
噪声
交通标志不要无脑做水平翻转,因为某些类别翻转后语义可能改变。
示例:
import albumentations as A
transform = A.Compose(
[
A.RandomBrightnessContrast(
p=0.3
),
A.GaussianBlur(
blur_limit=(3, 5),
p=0.1
),
A.ShiftScaleRotate(
shift_limit=0.05,
scale_limit=0.1,
rotate_limit=5,
p=0.3
)
],
bbox_params=A.BboxParams(
format="yolo",
label_fields=["class_labels"]
)
)
这里 bbox_params 很重要:因为增强不只是改像素,还要同步变换框。
假设 4 个类别:
path: D:/AI_Project/traffic_sign/dataset
train: images/train
val: images/val
names:
0: speed_limit
1: no_entry
2: stop
3: turn_left
只修改:
path: E:/projects/sign_dataset
同步修改:
names:
同时检查模型配置中的类别数。
教程假设预训练模型:
D:\AI_Project\traffic_sign\models\traffic_sign_pretrained.pt
创建:
train.py
from pathlib import Path
from ultralytics import YOLO
# ============================================================
# 需要根据实际环境修改
# ============================================================
PROJECT_ROOT = Path(
r"D:\AI_Project\traffic_sign"
)
MODEL_PATH = (
PROJECT_ROOT
/ "models"
/ "traffic_sign_pretrained.pt"
)
DATA_YAML = (
PROJECT_ROOT
/ "dataset"
/ "data.yaml"
)
OUTPUT_ROOT = (
PROJECT_ROOT
/ "task2"
)
def main():
model = YOLO(
str(MODEL_PATH)
)
model.train(
data=str(DATA_YAML),
epochs=100,
imgsz=640,
batch=4,
workers=4,
project=str(OUTPUT_ROOT),
name="traffic_sign",
pretrained=True
)
if __name__ == "__main__":
main()
data
→ 数据配置
epochs
→ 数据集循环次数
imgsz
→ 网络输入尺寸
batch
→ 每批图片数量
workers
→ 数据加载进程数
如果图片尺寸从:
640
改为:
512
训练和部署的预处理也应同步检查。
这是最重要的变化点之一。
如果实际项目是:
PyTorch 自定义模型
MMDetection
Detectron2
其他检测框架
不要强行使用:
from ultralytics import YOLO
主要替换:
模型加载
训练接口
验证接口
ONNX 导出接口
输出后处理
以下部分通常仍然保留:
数据清洗
LabelMe
标签转换
train/val
指标概念
部署流程
教程假设目录:
task2/
└── traffic_sign/
├── weights/
│ ├── best.pt
│ └── last.pt
└── results.csv
results.csv 本质是训练日志,常包含 epoch、loss、precision、recall、mAP 等信息。
不要只记住文件名 best.pt,更重要的是学会找到:
最佳 checkpoint 在哪里
不同框架命名可能不同。
两个框:
真实框
预测框
IoU:
IoU = 交集面积 / 并集面积
假设:
交集 = 80
并集 = 120
则:
IoU = 80 / 120 = 0.6667
教程假设匹配条件:
IOU_THRESHOLD = 0.5
如果实际项目使用 0.4 或 0.6,只修改这个参数以及相关评估逻辑。
def calculate_iou(box1, box2):
x1 = max(box1[0], box2[0])
y1 = max(box1[1], box2[1])
x2 = min(box1[2], box2[2])
y2 = min(box1[3], box2[3])
inter_w = max(
0,
x2 - x1
)
inter_h = max(
0,
y2 - y1
)
inter_area = (
inter_w * inter_h
)
area1 = (
max(0, box1[2] - box1[0])
*
max(0, box1[3] - box1[1])
)
area2 = (
max(0, box2[2] - box2[0])
*
max(0, box2[3] - box2[1])
)
union_area = (
area1 + area2 - inter_area
)
if union_area <= 0:
return 0.0
return inter_area / union_area
对于某一个类别:
TP
预测了,而且正确
FP
预测了,但是错误
FN
真实存在,但是没有找到
如果:
TP = 80
FP = 20
FN = 20
则:
Precision = 80 / 100 = 0.8
Recall = 80 / 100 = 0.8
公式:
Precision = TP / (TP + FP)
Recall = TP / (TP + FN)
F1 = 2 × Precision × Recall / (Precision + Recall)
Python:
precision = (
tp / (tp + fp)
if tp + fp > 0
else 0.0
)
recall = (
tp / (tp + fn)
if tp + fn > 0
else 0.0
)
f1 = (
2 * precision * recall
/ (precision + recall)
if precision + recall > 0
else 0.0
)
假设:
0 speed_limit
1 no_entry
2 stop
3 turn_left
矩阵:
预测
0 1 2 3
真实 0 50 2 1 0
真实 1 3 45 2 1
真实 2 0 2 48 1
真实 3 1 3 2 44
对角线通常是正确分类。
非对角线是类别混淆。
目标检测与普通分类不同,因为既要考虑:
类别是否正确
又要考虑:
位置是否正确
所以目标检测常用 mAP 来综合评价定位与分类表现。
如果使用 YOLO 风格接口,可以通过验证得到:
from ultralytics import YOLO
model = YOLO("best.pt")
metrics = model.val(
data="data.yaml"
)
print(
"mAP50:",
metrics.box.map50
)
print(
"mAP50-95:",
metrics.box.map
)
不要把 mAP50 和 mAP50-95 当成同一个指标。
分类任务的 ROC、PR 比较容易统一定义。
目标检测还涉及:
IoU 匹配
置信度阈值
重复框
类别策略
one-vs-rest 定义
所以不同项目可能采用不同统计口径。
本教程后面的示例使用一个明确的教学口径:
IoU ≥ 0.5
目标级匹配
one-vs-rest
如果实际项目已经提供官方评估函数,应优先采用它,而不是简单套用本教程的自定义实现。
每张图:
1. 读取 GT
2. 模型预测
3. 按 confidence 从高到低排序
4. 为每个预测框找 IoU 最大的 GT
5. IoU 达到阈值才匹配
6. 一个 GT 只能匹配一次
7. 未匹配预测 = FP
8. 未匹配 GT = FN
这是理解目标检测评估的关键。
下面假设:
模型 = YOLO 风格
标签 = YOLO txt
IoU = 0.5
from collections import defaultdict
from pathlib import Path
import numpy as np
import pandas as pd
from PIL import Image
from sklearn.metrics import (
confusion_matrix,
roc_curve,
auc,
precision_recall_curve,
)
import matplotlib.pyplot as plt
from ultralytics import YOLO
# ============================================================
# 需要根据实际环境修改
# ============================================================
PROJECT_ROOT = Path(
r"D:\AI_Project\traffic_sign"
)
MODEL_PATH = (
PROJECT_ROOT
/ "task2"
/ "traffic_sign"
/ "weights"
/ "best.pt"
)
IMAGE_DIR = (
PROJECT_ROOT
/ "dataset"
/ "images"
/ "val"
)
LABEL_DIR = (
PROJECT_ROOT
/ "dataset"
/ "labels"
/ "val"
)
OUTPUT_DIR = (
PROJECT_ROOT
/ "task3"
)
CLASS_NAMES = [
"speed_limit",
"no_entry",
"stop",
"turn_left",
]
NUM_CLASSES = len(CLASS_NAMES)
IOU_THRESHOLD = 0.5
def calculate_iou(box1, box2):
x1 = max(box1[0], box2[0])
y1 = max(box1[1], box2[1])
x2 = min(box1[2], box2[2])
y2 = min(box1[3], box2[3])
inter_w = max(0, x2 - x1)
inter_h = max(0, y2 - y1)
inter = inter_w * inter_h
area1 = (
max(0, box1[2] - box1[0])
* max(0, box1[3] - box1[1])
)
area2 = (
max(0, box2[2] - box2[0])
* max(0, box2[3] - box2[1])
)
union = area1 + area2 - inter
if union <= 0:
return 0.0
return inter / union
def read_yolo_labels(
label_path,
image_width,
image_height
):
if not label_path.exists():
return []
result = []
lines = label_path.read_text(
encoding="utf-8"
).splitlines()
for line in lines:
parts = line.split()
if len(parts) != 5:
continue
class_id = int(parts[0])
x_center = float(parts[1])
y_center = float(parts[2])
width = float(parts[3])
height = float(parts[4])
x1 = (
x_center - width / 2
) * image_width
y1 = (
y_center - height / 2
) * image_height
x2 = (
x_center + width / 2
) * image_width
y2 = (
y_center + height / 2
) * image_height
result.append({
"class_id": class_id,
"box": [
x1,
y1,
x2,
y2
]
})
return result
def match_predictions(
ground_truths,
predictions
):
predictions = sorted(
predictions,
key=lambda x: x["confidence"],
reverse=True
)
matched_gt = set()
matched_pairs = []
false_positives = []
for pred in predictions:
best_iou = 0.0
best_index = -1
for index, gt in enumerate(
ground_truths
):
if index in matched_gt:
continue
iou = calculate_iou(
pred["box"],
gt["box"]
)
if iou > best_iou:
best_iou = iou
best_index = index
if (
best_index >= 0
and best_iou >= IOU_THRESHOLD
):
matched_gt.add(best_index)
matched_pairs.append(
(
ground_truths[best_index],
pred,
best_iou
)
)
else:
false_positives.append(pred)
false_negatives = []
for index, gt in enumerate(
ground_truths
):
if index not in matched_gt:
false_negatives.append(gt)
return (
matched_pairs,
false_positives,
false_negatives
)
def main():
OUTPUT_DIR.mkdir(
parents=True,
exist_ok=True
)
model = YOLO(
str(MODEL_PATH)
)
y_true = []
y_pred = []
stats = defaultdict(
lambda: {
"tp": 0,
"fp": 0,
"fn": 0
}
)
for image_path in IMAGE_DIR.iterdir():
if image_path.suffix.lower() not in {
".jpg",
".jpeg",
".png",
".bmp"
}:
continue
label_path = (
LABEL_DIR
/ f"{image_path.stem}.txt"
)
with Image.open(image_path) as img:
image_width, image_height = img.size
gts = read_yolo_labels(
label_path,
image_width,
image_height
)
result = model.predict(
source=str(image_path),
verbose=False
)[0]
predictions = []
if result.boxes is not None:
boxes = result.boxes
xyxy = (
boxes.xyxy
.cpu()
.numpy()
)
cls = (
boxes.cls
.cpu()
.numpy()
.astype(int)
)
conf = (
boxes.conf
.cpu()
.numpy()
)
for i in range(len(xyxy)):
predictions.append({
"class_id": int(cls[i]),
"confidence": float(conf[i]),
"box": xyxy[i].tolist()
})
matched, fps, fns = match_predictions(
gts,
predictions
)
for gt, pred, iou in matched:
gt_class = gt["class_id"]
pred_class = pred["class_id"]
y_true.append(gt_class)
y_pred.append(pred_class)
if gt_class == pred_class:
stats[gt_class]["tp"] += 1
else:
stats[gt_class]["fn"] += 1
stats[pred_class]["fp"] += 1
for pred in fps:
stats[pred["class_id"]]["fp"] += 1
for gt in fns:
stats[gt["class_id"]]["fn"] += 1
rows = []
for class_id in range(NUM_CLASSES):
tp = stats[class_id]["tp"]
fp = stats[class_id]["fp"]
fn = stats[class_id]["fn"]
precision = (
tp / (tp + fp)
if tp + fp > 0
else 0.0
)
recall = (
tp / (tp + fn)
if tp + fn > 0
else 0.0
)
f1 = (
2 * precision * recall
/ (precision + recall)
if precision + recall > 0
else 0.0
)
rows.append({
"class_id": class_id,
"class_name": CLASS_NAMES[class_id],
"precision": precision,
"recall": recall,
"f1": f1,
"tp": tp,
"fp": fp,
"fn": fn
})
pd.DataFrame(rows).to_csv(
OUTPUT_DIR / "val.csv",
index=False,
encoding="utf-8-sig"
)
if y_true:
cm = confusion_matrix(
y_true,
y_pred,
labels=list(range(NUM_CLASSES))
)
else:
cm = np.zeros(
(NUM_CLASSES, NUM_CLASSES),
dtype=int
)
pd.DataFrame(
cm,
index=CLASS_NAMES,
columns=CLASS_NAMES
).to_csv(
OUTPUT_DIR / "matrix.csv",
encoding="utf-8-sig"
)
print("验证完成:", OUTPUT_DIR)
if __name__ == "__main__":
main()
说明:上面完整实现了检测框匹配、F1 与混淆矩阵的核心流程。ROC、PR 和 AUC 的实现需要明确“目标级样本如何定义”,因此建议沿用同一匹配策略,再根据项目要求扩展;不要把分类任务的 AUC 公式直接机械套在检测结果上。
对某个类别采用 one-vs-rest:
真实该类别 = 1
真实其他类别 = 0
然后为每个样本提供:
y_true
+
confidence score
ROC:
from sklearn.metrics import roc_curve, auc
fpr, tpr, thresholds = roc_curve(
y_true,
y_score
)
roc_auc = auc(
fpr,
tpr
)
PR:
from sklearn.metrics import precision_recall_curve
precision, recall, thresholds = (
precision_recall_curve(
y_true,
y_score
)
)
绘图:
plt.plot(fpr, tpr)
以及:
plt.plot(recall, precision)
保存:
plt.savefig("roc.jpg", dpi=200)
plt.savefig("pr.jpg", dpi=200)
如果类别没有正样本或负样本,AUC/ROC 可能无法计算,这不是 Python 语法错误,而是数据条件不足。
建议:
task3/
├── matrix.csv
├── val.csv
├── roc.jpg
└── pr.jpg
含义:
matrix.csv
→ 混淆矩阵
val.csv
→ Precision / Recall / F1 / TP / FP / FN
roc.jpg
→ ROC 曲线
pr.jpg
→ PR 曲线
如果使用 Ultralytics 风格模型:
from ultralytics import YOLO
model = YOLO("best.pt")
model.export(
format="onnx",
imgsz=640,
opset=17,
dynamic=True,
simplify=True
)
如果模型不属于这个框架,导出方法可能需要改成:
torch.onnx.export(...)
或者使用框架自己的 export API。
一个通用的教学示例:
from onnxruntime.quantization import (
quantize_dynamic,
QuantType,
)
quantize_dynamic(
"best.onnx",
"best_int8.onnx",
weight_type=QuantType.QUInt8
)
结果:
best.onnx
↓
best_int8.onnx
量化后必须重新验证,不要因为“文件变小了”就认为部署成功。
创建:
load_onnx.py
from pathlib import Path
import onnxruntime as ort
# ============================================================
# 需要根据实际环境修改
# ============================================================
MODEL_PATH = Path(
r"D:\AI_Project\traffic_sign\task4\best.onnx"
)
def main():
session = ort.InferenceSession(
str(MODEL_PATH),
providers=[
"CPUExecutionProvider"
]
)
print("输入:")
for item in session.get_inputs():
print(
"name =", item.name,
"shape =", item.shape,
"type =", item.type
)
print("\n输出:")
for item in session.get_outputs():
print(
"name =", item.name,
"shape =", item.shape,
"type =", item.type
)
if __name__ == "__main__":
main()
不要一开始就同时写:
图片读取
resize
normalize
NMS
类别解析
正确调试顺序:
第一步:ONNX 能否加载?
第二步:输入 name 是什么?
第三步:输入 shape 是什么?
第四步:输出 shape 是什么?
第五步:再写预处理。
第六步:再写后处理。
OpenCV/PIL 读取图片通常是:
H × W × C
神经网络常用:
C × H × W
转换:
image = image.transpose(2, 0, 1)
再增加 batch:
image = image[None, ...]
最终:
1 × C × H × W
import cv2
import numpy as np
def preprocess(
image_path,
image_size=640
):
image = cv2.imread(
str(image_path)
)
if image is None:
raise FileNotFoundError(
f"无法读取:{image_path}"
)
image = cv2.cvtColor(
image,
cv2.COLOR_BGR2RGB
)
image = cv2.resize(
image,
(image_size, image_size)
)
image = (
image.astype(np.float32)
/ 255.0
)
image = image.transpose(
2, 0, 1
)
image = image[None, ...]
return image
这里如果输入尺寸从:
640
改为:
512
必须同步修改:
训练 imgsz
验证输入
ONNX 预处理
先取得模型输入名称:
input_name = (
session.get_inputs()[0].name
)
再:
output = session.run(
None,
{
input_name: input_tensor
}
)
不要在没有检查的情况下写死:
"images"
因为不同模型输入名可能是:
images
input
pixel_values
原始图片
↓
Resize
↓
颜色通道处理
↓
Normalize
↓
HWC → CHW
↓
增加 Batch
↓
ONNX Runtime
↓
模型输出
↓
置信度过滤
↓
类别解析
↓
NMS
↓
最终边界框 + 类别
所以:
ONNX 文件能加载
并不等于:
完整部署已经完成
| 变化 | 主要修改位置 |
|---|---|
| SSH IP 变化 | SSH 登录命令 |
| SSH 端口变化 | SSH 登录命令 |
| Ubuntu 工作目录变化 | 所有 Path 配置 |
| Ollama 模型变化 | OLLAMA_MODEL |
| Ollama 地址变化 | OLLAMA_BASE_URL |
| Qwen 基础模型变化 | BASE_MODEL / MODEL_PATH |
| 文本文件名变化 | FILES |
| chunk 变化 | CHUNK_SIZE |
| GPU 显存变化 | batch、max_length、gradient accumulation |
| 类别名称变化 | CLASS_NAMES、data.yaml |
| 类别数量变化 | CLASS_NAMES、模型类别配置 |
| 图像路径变化 | PROJECT_ROOT、IMAGE_DIR |
| 标签路径变化 | LABEL_DIR |
| 预训练模型变化 | MODEL_PATH |
| 检测框架变化 | train.py、验证接口、ONNX 导出/后处理 |
| 输入尺寸变化 | imgsz、预处理、ONNX |
| ONNX 输入名变化 | 从 session.get_inputs() 动态读取 |
| ONNX 输出结构变化 | 后处理必须修改 |
| IoU 阈值变化 | IOU_THRESHOLD |
虽然模块 A 和模块 B 的模型不同,但底层技能高度相同:
Python
Linux / Windows
文件系统
路径
数据处理
模型加载
推理
训练
验证
量化
部署
异常排查
真正学会后,你拿到一个新的 AI 项目,也可以使用同一套思维。
不要从第一行机械看到最后一行。
先全局搜索:
model
然后:
dataset
train
val
batch
epochs
num_classes
pretrained
重点找:
模型在哪里加载
数据在哪里加载
训练在哪里开始
验证在哪里开始
结果保存在哪里
统一使用:
1. 看最后一行报错
2. 看哪个文件
3. 看哪一行
4. 判断问题类型
5. 只修改对应位置
6. 再运行验证
例如:
FileNotFoundError
→ 路径
JSONDecodeError
→ JSON 结构/模型输出
KeyError
→ 字段
CUDA out of memory
→ 显存
Connection refused
→ 服务地址/端口
练习:
pwd
ls
cd
mkdir
cp
mv
rm
cat
做到:
连接远程机器
打开远程目录
新建 Python
终端运行
修改代码
掌握:
Path
open
read
write
json.load
json.dump
掌握:
ChatOllama
invoke
Prompt
JSON
掌握:
AutoTokenizer
AutoModelForCausalLM
generate
decode
掌握:
4bit
BitsAndBytesConfig
LoraConfig
get_peft_model
Trainer
掌握:
模型格式
量化
token/s
本地推理
掌握:
项目结构
图片文件
LabelMe
JSON
YOLO 标签
掌握:
train
val
IoU
mAP
Precision
Recall
F1
掌握:
导出
量化
ONNX Runtime
输入输出
预处理
后处理
VS Code
↓
SSH
↓
Ubuntu
↓
TXT
↓
chunk
↓
Ollama
↓
Qwen3
↓
JSON
↓
dataset.json
↓
4bit
↓
LoRA
↓
QLoRA
↓
完整模型
↓
GGUF
↓
INT4
↓
self_check
↓
inference.py
↓
inference.sh
PyCharm
↓
Windows
↓
图片
↓
清洗
↓
LabelMe
↓
JSON
↓
YOLO 标签
↓
增强
↓
train/val
↓
预训练检测模型
↓
训练
↓
best model
↓
mAP/F1/混淆矩阵
↓
ROC/PR/AUC
↓
ONNX
↓
INT8
↓
ONNX Runtime
以后看到:
我要训练一个模型
你应该能自动分解:
数据在哪里?
↓
数据是什么格式?
↓
怎么清洗?
↓
怎么标注?
↓
怎么划分?
↓
模型在哪里?
↓
模型输入是什么?
↓
模型输出是什么?
↓
如何训练?
↓
如何验证?
↓
如何保存?
↓
如何量化?
↓
如何部署?
遇到陌生项目时,也优先把代码拆成:
数据层
模型层
训练层
验证层
部署层
环境变化时优先找:
配置变量
只有真正发生框架或模型接口变化时,再修改核心代码。
文本大模型方向对应的核心文件:
data/dataset.json
inference.py
qlora.py
checkpoint-best/
quantization/quant_config.txt
quantization/model_int4.gguf
quantization/self_check_report.txt
submission/inference.py 或 inference.sh
submission/README_submission.txt
目标检测方向对应的核心成果:
训练日志 results.csv
验证结果 matrix.csv
验证指标 val.csv
ROC 图 roc.jpg
PR 图 pr.jpg
量化前模型
量化后模型
ONNX 模型
load_onnx.py
这些文件对应的任务结构、路径关系和操作要求来自所提供的任务说明;教程中额外增加的示例代码、阈值、YOLO 框架假设等,都是为了帮助学习者理解完整实现,并已在对应章节明确标注为假设。fileciteturn0file0L55-L120 fileciteturn0file0L127-L197 fileciteturn0file0L288-L334
[ ] VS Code Remote SSH 正常
[ ] whoami / pwd / ls 正常
[ ] 三个 TXT 可读取
[ ] chunk 正常
[ ] Ollama 正常
[ ] Qwen3 正常
[ ] Prompt 能生成 JSON
[ ] dataset.json 正常
[ ] 数据数量已统计
[ ] 文件传输完成
[ ] 基础模型可以加载
[ ] inference.py 正常
[ ] 4bit 加载成功
[ ] LoRA 目标模块名称正确
[ ] QLoRA 训练启动
[ ] Adapter 保存成功
[ ] LoRA 合并成功
[ ] checkpoint-best 可加载
[ ] GGUF 转换成功
[ ] INT4 模型生成
[ ] self_check 正常
[ ] token/s 已记录
[ ] submission 目录完整
[ ] inference.sh 可运行
[ ] PyCharm 打开项目
[ ] 原始图片存在
[ ] 重复图片清理
[ ] 损坏图片清理
[ ] LabelMe 标注完成
[ ] 类别名称统一
[ ] JSON → YOLO 标签完成
[ ] 图片与标签一一对应
[ ] train/val 完整
[ ] data.yaml 正确
[ ] 模型可以加载
[ ] 类别数正确
[ ] 训练参数正确
[ ] results.csv 产生
[ ] 最优模型找到
[ ] mAP 得到
[ ] F1 得到
[ ] matrix.csv 得到
[ ] val.csv 得到
[ ] roc.jpg 得到
[ ] pr.jpg 得到
[ ] ONNX 导出成功
[ ] INT8 量化成功
[ ] ONNX Runtime 能加载
[ ] 输入输出 shape 已确认
[ ] 完整推理成功
不要因为环境变化就把整份代码重写。
先找配置:
MODEL_PATH = "..."
DATASET_PATH = "..."
NUM_CLASSES = 4
IMAGE_SIZE = 640
BATCH_SIZE = 4
然后:
改配置
↓
运行
↓
查看真实报错
↓
定位具体模块
↓
只修改必要代码
这样学习一段时间后,你会从“会写 Python”逐渐变成“能独立完成一个人工智能项目的完整实操流程”。
— AI模型技术交流教程 · 仅供学习交流 —