File size: 9,793 Bytes
1b9a143 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
example.py — MLX 版 model2vec 静态嵌入模型的最小可用示例(任一变体目录下都有同一份)。
运行环境:Apple Silicon (M 系列) + macOS,安装 `pip install mlx`。
MLX 不支持 Intel Mac / Linux / Windows。
运行:
python example.py
python example.py "Hello world" "Bonjour le monde" "你好世界"
API:
from example import StaticEmbeddingMLX
model = StaticEmbeddingMLX.from_dir(".") # 自动加载同目录的 weights.npz / config.json
vec = model.encode("Bonjour le monde") # -> mx.array, shape (256,)
mat = model.encode(["en", "fr", "zh"]) # -> (3, 256)
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
# MLX 只支持 Apple Silicon;这里延迟到真正推理时才 import,便于在其它机器上做静态检查。
mx = None # 由 _load_mlx() 注入
HERE = Path(__file__).resolve().parent
def _load_mlx():
"""延迟导入 mlx.core,并给出清晰的错误提示。"""
global mx
if mx is None:
try:
import mlx.core as _mx
except ImportError as exc: # pragma: no cover - 仅非 Apple Silicon 上触发
raise SystemExit(
"未能导入 mlx.core。MLX 需要 Apple Silicon (M 系列) + macOS,"
"请先 `pip install mlx`。Intel Mac / Linux / Windows 无法运行。"
) from exc
mx = _mx
return mx
class Model2VecTokenizer:
"""复刻 model2vec.StaticModel.tokenize:add_special_tokens=False + 丢弃 unk + 截断。
model2vec 在 tokenize 前会先按字符粗截一遍(max_length * median_token_length),
再做 token 级截断;两者都在这里复刻,保证与官方 Hub 模型的输出对齐。
"""
def __init__(self, tokenizer_path: Path, config: dict | None = None):
cfg = config or {}
from tokenizers import Tokenizer
self.tokenizer = Tokenizer.from_file(str(tokenizer_path))
if self.tokenizer.padding is not None:
self.tokenizer.no_padding()
self.max_length = cfg.get("max_length")
self.median_token_length = int(cfg.get("median_token_length") or 0)
self.unk_token_id = cfg.get("unk_token_id")
self._char_limit = (
None if (self.max_length is None or not self.median_token_length)
else self.max_length * self.median_token_length
)
def __call__(self, sentences: list[str]) -> list[list[int]]:
if self.max_length is not None:
self.tokenizer.enable_truncation(self.max_length)
else:
self.tokenizer.no_truncation()
texts = [s[: self._char_limit] for s in sentences] if self._char_limit else list(sentences)
ids = [enc.ids for enc in self.tokenizer.encode_batch_fast(texts, add_special_tokens=False)]
if self.unk_token_id is not None:
ids = [[i for i in row if i != self.unk_token_id] for row in ids]
return ids
class StaticEmbeddingMLX:
"""量化 model2vec 的 MLX 实现:Gather → 反量化 → 均值池化 → L2 归一化。
量化布局(与 convert_mlx.py 一一对应):
INT8: embeddings int8 [V, D] , 对称: x = q * scale
scales fp16 [V, D/group]
INT4: embeddings uint8 [V, D/2] , 非对称: x = q * scale + bias
scales fp16 [V, D/group] 低 4 位 = 偶数维, 高 4 位 = 奇数维
biases fp16 [V, D/group]
这里刻意只用最基础的 MLX 算子(索引/reshape/逐元素运算),不依赖
nn.QuantizedEmbedding 的内部打包格式,避免 mlx 版本差异导致加载失败。
"""
def __init__(self, weights_npz: Path, config: dict, tokenizer_path: Path | None = None):
self.config = config
self.bits = int(config["quantization"]["bits"])
self.group_size = int(config["quantization"]["group_size"])
self.dim = int(config["dim"])
self.vocab_size = int(config["vocab_size"])
core = _load_mlx()
arrays = core.load(str(weights_npz))
self.embeddings = arrays["embeddings"]
self.scales = arrays["scales"]
self.biases = arrays.get("biases") if hasattr(arrays, "get") else None
self.tokenizer = Model2VecTokenizer(tokenizer_path or Path(weights_npz).parent / "tokenizer.json", config)
@classmethod
def from_dir(cls, directory) -> "StaticEmbeddingMLX":
d = Path(directory)
cfg = json.loads((d / "config.json").read_text(encoding="utf-8"))
return cls(d / "weights.npz", cfg, d / "tokenizer.json")
# ------------------------------------------------------------------ #
def _dequantize(self, rows, scales_g, biases_g, batch: int, seq: int):
"""把查表得到的量化行还原成 float32 的 token 向量 [batch, seq, dim]。"""
core = mx
x = rows.astype(core.float32)
if self.bits == 4:
# 低 4 位 / 高 4 位拆包(纯浮点算术,避免依赖整数取模语义)
hi = core.floor(x / 16.0)
lo = x - 16.0 * hi
x = core.stack([lo, hi], axis=-1).reshape(batch, seq, self.dim)
groups = self.dim // self.group_size
# [batch, seq, dim] -> [batch, seq, groups, group_size] 后按组广播 scale/bias
x = x.reshape(batch, seq, groups, self.group_size)
x = x * scales_g.astype(core.float32)[..., None]
if biases_g is not None:
x = x + biases_g.astype(core.float32)[..., None]
return x.reshape(batch, seq, self.dim)
def embed_ids(self, input_ids: np.ndarray, attention_mask: np.ndarray):
"""input_ids / attention_mask: numpy int64 [batch, seq] -> [batch, dim]。"""
core = mx
ids = core.array(input_ids)
rows = self.embeddings[ids] # [batch, seq, storage_dim]
batch, seq = input_ids.shape
scales_g = self.scales[ids] # [batch, seq, groups]
biases_g = None if self.biases is None else self.biases[ids]
x = self._dequantize(rows, scales_g, biases_g, batch, seq)
mask_f = core.array(attention_mask.astype(np.float32))
masked = x * mask_f.reshape(batch, seq, 1)
token_sum = core.sum(masked, axis=1) # [batch, dim]
n_tokens = core.sum(mask_f, axis=1) # [batch]
n_tokens = core.maximum(n_tokens, core.array([1.0])) # 防止 0 除
mean = token_sum / n_tokens.reshape(batch, 1)
n2 = core.sum(mean * mean, axis=1, keepdims=True)
norm = core.sqrt(core.maximum(n2, core.array([[1e-12]])))
return mean / norm
def encode(self, sentences, batch_size: int = 256, sort_by_length: bool = True):
"""把句子编码成 L2 归一化的向量,返回 mx.array。"""
mx_local = _load_mlx()
single = isinstance(sentences, str)
texts = [sentences] if single else list(sentences)
ids = self.tokenizer(texts)
order = sorted(range(len(texts)), key=lambda i: len(ids[i])) if sort_by_length else list(range(len(texts)))
outs = {}
for start in range(0, len(order), batch_size):
batch_idx = order[start : start + batch_size]
rows = [ids[i] for i in batch_idx]
width = max([len(r) for r in rows] + [1])
arr_ids = np.zeros((len(rows), width), dtype=np.int64)
arr_mask = np.zeros((len(rows), width), dtype=np.int64)
for i, r in enumerate(rows):
if r:
arr_ids[i, : len(r)] = r
arr_mask[i, : len(r)] = 1
out = self.embed_ids(arr_ids, arr_mask) # mx.array [batch, dim]
for i, v in zip(batch_idx, out):
outs[i] = v
# 全程保持在 MLX 张量域内拼结果,避免依赖不同版本的 MLX <-> numpy 互操作
stacked = mx_local.stack([outs[i] for i in range(len(texts))], axis=0)
return stacked[0] if single else stacked
DEMO_SENTENCES = [
"The capital of Denmark is Copenhagen", # en
"København er hovedstaden i Danmark", # da
"Le machine learning change le monde", # fr
"机器学习正在改变世界", # zh
"機械学習は世界を変えつつある", # ja
"¿Cómo llego al aeropuerto desde aquí?", # es
"How do I get to the airport from here?", # en
"Was kostet ein Ticket zum Hauptbahnhof?", # de
]
def to_numpy(x):
"""MLX array -> numpy。优先用 __array__ 互操作,老版本退回 tolist()。"""
try:
return np.asarray(x)
except Exception: # noqa: BLE001
return np.array(x.tolist(), dtype=np.float32)
def main():
model = StaticEmbeddingMLX.from_dir(HERE)
cfg = model.config
size_mb = (HERE / "weights.npz").stat().st_size / 1e6
print(f"权重: {HERE / 'weights.npz'} ({size_mb:.2f} MB)")
print(f"配置: variant={cfg.get('variant')} bits={model.bits} group_size={model.group_size} "
f"dim={model.dim} vocab={model.vocab_size} scheme={cfg['quantization'].get('scheme')}")
emb = to_numpy(model.encode(DEMO_SENTENCES))
print(f"\n向量 shape={emb.shape}, ||v||={np.linalg.norm(emb[0]):.6f}")
sim = emb @ emb.T
print("\n余弦相似度矩阵:")
print(" " + " ".join(f"{i:>6}" for i in range(len(DEMO_SENTENCES))))
for i, row in enumerate(sim):
print(f"[{i}] " + " ".join(f"{v:6.3f}" for v in row) + f" {DEMO_SENTENCES[i][:40]}")
off = sim[~np.eye(len(sim), dtype=bool)]
print(f"\n非对角相似度范围: [{off.min():.3f}, {off.max():.3f}]")
if __name__ == "__main__":
main()
|