〇、准备工作:pip 与清华镜像源

教程中会用到大量第三方库。国内网络环境下,推荐直接使用清华 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 文件”这么简单,而是一条完整流水线。

1.1 模块 A:大语言模型

原始 TXT
   ↓
读取文本
   ↓
文本分块 chunk
   ↓
Ollama + Qwen3 7B
   ↓
生成 instruction / input / output
   ↓
dataset.json
   ↓
Qwen3 7B 基础模型
   ↓
4bit + LoRA
   ↓
QLoRA 微调
   ↓
LoRA 合并
   ↓
完整模型
   ↓
GGUF
   ↓
INT4
   ↓
本地自检
   ↓
最终推理脚本

1.2 模块 B:目标检测

图片/视频
   ↓
数据筛选
   ↓
数据清洗
   ↓
LabelMe 标注
   ↓
标签格式转换
   ↓
数据增强
   ↓
训练集 / 验证集
   ↓
目标检测模型
   ↓
训练
   ↓
训练日志
   ↓
验证
   ↓
mAP / Precision / Recall / F1
   ↓
混淆矩阵 / ROC / PR / AUC
   ↓
量化
   ↓
ONNX
   ↓
ONNX Runtime 部署

二、模块 A:VS Code + SSH + Ubuntu

2.1 假设环境

项目 假设值
本地系统 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

三、VS Code 通过 SSH 连接 Ubuntu

3.1 SSH 是什么

可以简单理解为:

Windows 电脑
    ↓ SSH
Ubuntu 远程机器

你在 VS Code 里写代码,但是 Python 实际运行在 Ubuntu 上。

因此:

VS Code = 编码界面
SSH = 远程连接通道
Ubuntu = 实际运行代码和模型的机器

3.2 添加远程主机

在 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

四、SSH 连接成功后的第一组操作

不要一连接成功就开始写模型代码,先确认环境真的进入了远程机器。

打开:

Terminal → New Terminal

依次执行:

whoami

查看用户名。

hostname

查看主机名。

pwd

查看当前目录。

ls

查看文件。

ls -lh

查看文件大小和权限。

ls -lh /home/user/workspace

确认工作目录。


五、必须掌握的 Linux 命令

5.1 目录

cd /home/user/workspace

进入目录。

cd ..

返回上一层。

cd ~

回到当前用户主目录。

5.2 创建目录

mkdir model_b

多级目录:

mkdir -p /home/user/workspace/model_b/data

5.3 文件

ls -lh
cat test.txt
less test.txt

5.4 复制、移动、删除

cp a.txt b.txt
cp -r data backup
mv a.txt data/
rm a.txt
rm -rf test

rm -rf 删除前一定确认当前路径。


六、建立模块 A 的目录结构

执行:

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


七、读取 TXT 文件

先不接模型,第一步只验证文件读取。

创建:

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 怎么处理

例如:

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

假设一篇文本有几万字,不能简单地一次全部交给模型。

本教程假设:

chunk_size = 512

9.1 基本函数

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

9.2 理解 Python 切片

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 数切片

这属于算法实现变化,而不是简单改参数。


十、连接 Ollama + Qwen3

先在 Ubuntu 终端验证:

ollama list

确认有:

qwen3:7b

再运行:

ollama run qwen3:7b

输入:

你是谁?

有正常返回后再用 Python。

10.1 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"

只改模型名。

Ollama 地址变化

原来:

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

十二、设计数据生成 Prompt

我们需要让模型把普通文本转成:

{
  "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

16.1 调小 chunk

修改:

CHUNK_SIZE = 400

16.2 一个 chunk 产生多个问题

此时返回值从:

dict

变为:

list[dict]

程序结构也要相应修改。

16.3 多轮生成

可以让同一个 chunk 分别生成:

事实类问题
原因类问题
影响类问题
概念解释类问题

但必须去重和人工抽查,不能只是复制同一个问题。


十七、通过 FTP/SFTP 传输文件

可以把两类工具分开理解:

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 与 generate 的核心思想

文字
↓
tokenizer
↓
Token IDs
↓
模型
↓
Token IDs
↓
decode
↓
文字

所以:

tokenizer(...)

是文字到数字。

model.generate(...)

是生成新的 token。

tokenizer.decode(...)

是数字回文字。


二十、QLoRA:4bit + LoRA

完整微调:

基础模型
↓
大量参数参与更新

LoRA:

基础模型冻结
+
额外的小矩阵参与训练

QLoRA:

基础模型
↓
4bit 量化
↓
冻结
↓
LoRA
↓
训练 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 完整示例

创建:

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 怎么排查

如果看到:

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

二十五、LoRA 合并

训练得到的是:

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,而不是完整合并后的模型。


二十六、量化:GGUF 和 INT4 不要混淆

INT4
=
一种低位宽量化表示
GGUF
=
一种模型文件格式

因此通常是:

完整模型
↓
GGUF
↓
INT4

建立:

quant_config.txt

示例:

量化方案:INT4 + GGUF

选择理由:
INT4 可以降低模型存储空间和推理资源占用,
适合显存和内存有限的本地部署环境。

输出格式:GGUF
量化方式:Q4_0
主要工具:llama.cpp

二十七、llama.cpp 转换

不同版本的 llama.cpp 可能提供不同名称的转换脚本,因此这里按两种情况理解。

情况 A:使用 convert.py

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

情况 B:脚本名称变化

先查看:

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()

二十九、token/s 怎么算

如果:

生成 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

30.1 inference.py

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()

30.2 inference.sh

#!/bin/bash

# 依赖示例:
# pip install llama-cpp-python

# 运行示例:
# bash inference.sh --input "什么是尿毒性心包炎?"

python3 inference.py "$@"

30.3 测试

cd /home/user/workspace/model_b/submission
bash inference.sh --input "什么是金融危机?"

标准输入测试:

echo "什么是人工智能?" | python3 inference.py

三十一、模块 A 的最终目录

/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

三十二、模块 A 常见报错与定位思路

报错 优先检查
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:PyCharm + Windows

模块 B 假设全部在 Windows 本地完成。

33.1 假设环境

项目 假设值
操作系统 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 打开项目

打开 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

三十五、任务 B-1:数据清洗

清洗目标:

删除重复图片
删除损坏图片
删除明显不合格图片
统一文件命名
保证图片和标签可对应

三十六、使用 MD5 检查重复图片

创建:

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 标注

打开 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 文件中手工重复编号。


四十二、理解 LabelMe JSON

典型结构:

{
  "shapes": [
    {
      "label": "speed_limit",
      "points": [
        [100, 120],
        [220, 260]
      ]
    }
  ]
}

真正有用的信息主要是:

label
points

即:

类别
+
边界框坐标

四十三、LabelMe → YOLO 标签格式

假设:

图片宽: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

四十四、LabelMe → YOLO 完整代码

创建:

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 很重要:因为增强不只是改像素,还要同步变换框。


四十八、data.yaml

假设 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:

同时检查模型配置中的类别数。


四十九、PyCharm 中训练 YOLO 风格模型

教程假设预训练模型:

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

训练和部署的预处理也应同步检查。


五十一、如果实际模型不是 YOLO

这是最重要的变化点之一。

如果实际项目是:

PyTorch 自定义模型
MMDetection
Detectron2
其他检测框架

不要强行使用:

from ultralytics import YOLO

主要替换:

模型加载
训练接口
验证接口
ONNX 导出接口
输出后处理

以下部分通常仍然保留:

数据清洗
LabelMe
标签转换
train/val
指标概念
部署流程

五十二、训练结果与 best 模型

教程假设目录:

task2/
└── traffic_sign/
    ├── weights/
    │   ├── best.pt
    │   └── last.pt
    └── results.csv

results.csv 本质是训练日志,常包含 epoch、loss、precision、recall、mAP 等信息。

不要只记住文件名 best.pt,更重要的是学会找到:

最佳 checkpoint 在哪里

不同框架命名可能不同。


五十三、目标检测验证:先理解 IoU

两个框:

真实框
预测框

IoU:

IoU = 交集面积 / 并集面积

假设:

交集 = 80
并集 = 120

则:

IoU = 80 / 120 = 0.6667

教程假设匹配条件:

IOU_THRESHOLD = 0.5

如果实际项目使用 0.4 或 0.6,只修改这个参数以及相关评估逻辑。


五十四、IoU Python 实现

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
预测了,而且正确

FP
预测了,但是错误

FN
真实存在,但是没有找到

如果:

TP = 80
FP = 20
FN = 20

则:

Precision = 80 / 100 = 0.8

Recall = 80 / 100 = 0.8

五十六、Precision、Recall、F1

公式:

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

目标检测与普通分类不同,因为既要考虑:

类别是否正确

又要考虑:

位置是否正确

所以目标检测常用 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
)

不要把 mAP50mAP50-95 当成同一个指标。


五十九、ROC / PR / AUC 在检测中的特别说明

分类任务的 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 公式直接机械套在检测结果上。


六十二、ROC / PR 的教学实现思路

对某个类别采用 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 语法错误,而是数据条件不足。


六十三、模块 B 最终验证文件

建议:

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 曲线

六十四、ONNX 导出

如果使用 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。


六十五、ONNX 量化

一个通用的教学示例:

from onnxruntime.quantization import (
    quantize_dynamic,
    QuantType,
)


quantize_dynamic(
    "best.onnx",
    "best_int8.onnx",
    weight_type=QuantType.QUInt8
)

结果:

best.onnx
↓
best_int8.onnx

量化后必须重新验证,不要因为“文件变小了”就认为部署成功。


六十六、最基本的 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()

六十七、为什么先只打印 ONNX 输入输出

不要一开始就同时写:

图片读取
resize
normalize
NMS
类别解析

正确调试顺序:

第一步:ONNX 能否加载?

第二步:输入 name 是什么?

第三步:输入 shape 是什么?

第四步:输出 shape 是什么?

第五步:再写预处理。

第六步:再写后处理。

六十八、HWC、CHW 和 Batch

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 预处理

七十、ONNX Runtime 推理

先取得模型输入名称:

input_name = (
    session.get_inputs()[0].name
)

再:

output = session.run(
    None,
    {
        input_name: input_tensor
    }
)

不要在没有检查的情况下写死:

"images"

因为不同模型输入名可能是:

images
input
pixel_values

七十一、ONNX 部署完整思维

原始图片
↓
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_NAMESdata.yaml
类别数量变化 CLASS_NAMES、模型类别配置
图像路径变化 PROJECT_ROOTIMAGE_DIR
标签路径变化 LABEL_DIR
预训练模型变化 MODEL_PATH
检测框架变化 train.py、验证接口、ONNX 导出/后处理
输入尺寸变化 imgsz、预处理、ONNX
ONNX 输入名变化 session.get_inputs() 动态读取
ONNX 输出结构变化 后处理必须修改
IoU 阈值变化 IOU_THRESHOLD

七十三、两个模块最核心的共同能力

虽然模块 A 和模块 B 的模型不同,但底层技能高度相同:

Python
Linux / Windows
文件系统
路径
数据处理
模型加载
推理
训练
验证
量化
部署
异常排查

真正学会后,你拿到一个新的 AI 项目,也可以使用同一套思维。


七十四、看到陌生 Python 项目时怎么读

不要从第一行机械看到最后一行。

先全局搜索:

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
→ 服务地址/端口

七十六、推荐的完整学习顺序

第一阶段:Linux + SSH

练习:

pwd
ls
cd
mkdir
cp
mv
rm
cat

第二阶段:VS Code Remote SSH

做到:

连接远程机器
打开远程目录
新建 Python
终端运行
修改代码

第三阶段:Python 文件操作

掌握:

Path
open
read
write
json.load
json.dump

第四阶段:Ollama + LangChain

掌握:

ChatOllama
invoke
Prompt
JSON

第五阶段:Transformers

掌握:

AutoTokenizer
AutoModelForCausalLM
generate
decode

第六阶段:LoRA / QLoRA

掌握:

4bit
BitsAndBytesConfig
LoraConfig
get_peft_model
Trainer

第七阶段:GGUF / INT4

掌握:

模型格式
量化
token/s
本地推理

第八阶段:PyCharm + CV

掌握:

项目结构
图片文件
LabelMe
JSON
YOLO 标签

第九阶段:目标检测训练

掌握:

train
val
IoU
mAP
Precision
Recall
F1

第十阶段:ONNX

掌握:

导出
量化
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 框架假设等,都是为了帮助学习者理解完整实现,并已在对应章节明确标注为假设。fileciteturn0file0L55-L120 fileciteturn0file0L127-L197 fileciteturn0file0L288-L334


八十、最终检查清单

模块 A

[ ] 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 可运行

模块 B

[ ] 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模型技术交流教程 · 仅供学习交流 —