Notebook

第9章 学生Notebook:看图盘点助手

把"看起来像"变成证据分级

本Notebook完成以下任务:

  1. 用开放提问和约束提问分别描述图片
  2. 对模型每句结论做证据分级(A/B/C)
  3. 用扰动(遮挡/裁剪/降质)检查模型边界
  4. 修改一个变量,观察变化

准备:确认 data/ground_truth.json 存在。

1. 加载数据

读取图片标注和标准答案。

import json
import numpy as np
from pathlib import Path

data_path = Path('../data/ground_truth.json')
with open(data_path, 'r', encoding='utf-8') as f:
    data = json.load(f)

images = data['images']
questions = data['questions']

print(f'图片数量: {len(images)}')
print(f'提问方式: {len(questions)}种')
print()
for img in images:
    n_items = len(img['items'])
    print(f'  {img["id"]}: {img["description"]} ({n_items}个已知物品)')
print()
print(f'开放提问: {questions["open"]}')
print(f'约束提问: {questions["constrained"]}')

2. 获取模型描述

三种路径:

  1. 本地视觉模型(Qwen-VL via Ollama)
  2. 课程云端视觉API
  3. 预录输出(离线兜底)

下面的代码自动检测可用路径。

import os

# 预录的模型输出(离线兜底)
RECORDED_OUTPUTS = {
    'desk_normal': {
        'open': '图片中有一台笔记本电脑、一本蓝色封面的书、一支铅笔和一个白色杯子。',
        'constrained': '1. 笔记本电脑,位于桌面中央偏左。2. 蓝色封面书本,位于右上角。3. 铅笔,位于笔记本右侧。4. 白色杯子,位于右下角。'
    },
    'desk_occluded': {
        'open': '图片中有一台笔记本电脑、一些书和文具。看起来还有一个杯子。',
        'constrained': '1. 笔记本电脑,位于桌面中央,可以确认。2. 书本的一部分,右上角露出蓝色封面,但不确定完整书名。3. 疑似有铅笔,被笔记本压住,只能看到一部分。'
    },
    'desk_dark': {
        'open': '图片中有一台发光的笔记本电脑,桌上还有一些物品但不太清楚。可能有一本书和一个杯子。',
        'constrained': '1. 笔记本电脑,屏幕发光可以确认。2. 其他区域太暗,无法确认具体物品。'
    },
    'desk_tricky': {
        'open': '图片中有一台笔记本电脑、一个咖啡杯和一支铅笔。',
        'constrained': '1. 笔记本电脑,可以确认。2. 咖啡杯——需要确认是真实杯子还是图片中的杯子。3. 铅笔,可以确认。'
    },
    'desk_cluttered': {
        'open': '图片中有很多物品,包括笔记本、手机、耳机、钥匙、文具、水杯、书本、眼镜等。',
        'constrained': '桌面上有很多小物品密集排列。可以确认有笔记本电脑和一些文具。其他物品太小或太密集,无法逐一确认。'
    },
    'desk_empty': {
        'open': '图片中有一张木色桌面,上面什么都没有。可能远处有一个小物件。',
        'constrained': '桌面基本为空。没有可以确认的物品。'
    }
}

def get_model_description(image_id, question_type):
    """获取模型描述:尝试本地模型,失败则用预录输出"""
    # 尝试本地视觉模型
    try:
        import urllib.request
        img_path = Path(f'../data/images/{image_id}.png')
        if img_path.exists():
            import base64
            img_data = base64.b64encode(img_path.read_bytes()).decode()
            url = 'http://localhost:11434/api/generate'
            q = questions[question_type]
            payload = json.dumps({
                'model': 'qwen2.5-vl:7b',
                'prompt': q,
                'images': [img_data],
                'stream': False
            }).encode()
            req = urllib.request.Request(url, data=payload, headers={'Content-Type': 'application/json'})
            with urllib.request.urlopen(req, timeout=30) as resp:
                result = json.loads(resp.read())
                return result['response'], 'local_vlm'
    except Exception:
        pass
    # 离线兜底
    return RECORDED_OUTPUTS.get(image_id, {}).get(question_type, '无可用输出'), 'recorded'

# 测试
desc, source = get_model_description('desk_normal', 'open')
print(f'模型路径: {source}')
print(f'desk_normal 开放提问回答: {desc}')

3. 开放提问 vs 约束提问

运行:对每张图片,分别用开放和约束提问获取描述。

print(f'{"图片":<20} {"开放提问":<40} {"约束提问":<40}')
print('-' * 100)

for img in images:
    open_desc, _ = get_model_description(img['id'], 'open')
    const_desc, _ = get_model_description(img['id'], 'constrained')
    print(f'{img["id"]:<20}')
    print(f'  开放: {open_desc[:60]}...' if len(open_desc) > 60 else f'  开放: {open_desc}')
    print(f'  约束: {const_desc[:60]}...' if len(const_desc) > 60 else f'  约束: {const_desc}')
    print()

思考:

  1. 约束提问是否让模型更谨慎?
  2. 有没有开放提问中"看到"但约束提问中"不确定"的物品?
  3. desk_empty 的开放提问中,模型是否编造了物品?

4. 证据分级

对 desk_normal 和 desk_tricky 的约束提问回答,逐句做证据分级。

  • A(画面直接可见):能在原图中指出该物品
  • B(疑似):有局部特征但不能完全确认
  • C(无法确认):画面中没有足够证据
def grade_claims(image_id, model_description, ground_truth_items):
    """对模型描述中的每个声明做证据分级"""
    gt_names = {item['name'] for item in ground_truth_items}
    gt_evidence = {item['name']: item['evidence'] for item in ground_truth_items}
    
    results = []
    # 简单关键词匹配(教学演示用)
    for item in ground_truth_items:
        name = item['name']
        # 检查模型是否提到了这个物品
        mentioned = any(keyword in model_description for keyword in name.split('(')[0].split())
        if mentioned:
            results.append({
                'item': name,
                'model_mentioned': True,
                'ground_truth_evidence': item['evidence'],
                'student_grade': ''  # 学生填写
            })
    return results

# desk_normal 证据分级
normal_img = images[0]
normal_desc, _ = get_model_description('desk_normal', 'constrained')
grades = grade_claims('desk_normal', normal_desc, normal_img['items'])

print('desk_normal 证据分级:')
print(f'{"物品":<20} {"模型提到":<10} {"标准答案":<10} {"你的分级":<10}')
print('-' * 50)
for g in grades:
    print(f'{g["item"]:<20} {"是" if g["model_mentioned"] else "否":<10} {g["ground_truth_evidence"]:<10} {g["student_grade"] or "待填写":<10}')

print()
# desk_tricky 证据分级(注意陷阱)
tricky_img = images[3]
tricky_desc, _ = get_model_description('desk_tricky', 'constrained')
print(f'desk_tricky 约束提问回答: {tricky_desc}')
print(f'陷阱: {tricky_img.get("trap", "无")}')
print('模型是否把打印图片中的咖啡杯当成了真实杯子?')

5. 扰动测试

用程序模拟遮挡、裁剪和低清效果,观察模型输出变化。

注意:如果没有真实图片文件,这一步使用预录的扰动输出。

# 预录的扰动输出
PERTURBATION_OUTPUTS = {
    'desk_normal_cropped': '图片中有一台笔记本电脑的局部,看起来还有书的一角。',
    'desk_normal_lowres': '图片中有一台笔记本电脑,其他物品不太清楚。可能有一个杯子。',
    'desk_normal_occluded': '图片中有一台笔记本电脑,右上角被遮挡。可以看到铅笔的一部分。'
}

print('desk_normal 扰动对照:')
print()
print(f'原始: {RECORDED_OUTPUTS["desk_normal"]["constrained"]}')
print()
for key, output in PERTURBATION_OUTPUTS.items():
    label = key.replace('desk_normal_', '')
    print(f'{label}: {output}')
    print()

# 分析哪些结论在扰动后改变了
print('扰动影响分析:')
print('  裁剪后: 杯子消失了(被裁掉)')
print('  低清后: 铅笔变成"可能"(细节丢失)')
print('  遮挡后: 书和铅笔变成部分可见')

6. B档修改

从以下两项中选择一项:

选项A:为证据分级新增一条规则

  • 例如:"如果模型提到颜色但图片是黑白的,标为C"
  • 先预测这条规则会影响哪些结论

选项B:修改约束提问的措辞

  • 让提问更严格或更宽松
  • 先预测模型回答会怎样变化
# 我选择:选项___
# 
# 修改前预测:
# 

# === 选项A:新增证据分级规则 ===
# new_rule = "如果___,则标为___"
# print(f'新增规则: {new_rule}')
# print('受影响的结论:')

# === 选项B:修改约束提问 ===
# modified_question = questions['constrained'] + ' 额外要求:___'
# print(f'修改后的提问: {modified_question}')

7. 验收

print('验收清单:')
checks = [
    '运行了开放提问和约束提问对照',
    '完成了 desk_normal 和 desk_tricky 的证据分级',
    '分析了至少一种扰动的影响',
    '完成了B档修改',
    '能指出模型编造的内容(desk_empty 或 desk_tricky)',
]
for c in checks:
    print(f'  [ ] {c}')

print()
print('关键概念检查:')
print('  1. 视觉模型的输入是___,输出是___')
print('  2. 视觉幻觉的常见原因有___')
print('  3. 证据分级的三个等级是___')
print('  4. 规则模型和视觉模型的分工是___')