TinyMOSS-Diarize / runtime /quantlib /embed_quant.py
yongyizang's picture
TinyMOSS-Diarize: 2.911-bit packed weights, runtime, and model card
7ccb33d verified
Raw
History Blame Contribute Delete
27.3 kB
"""Embedding / tied lm_head fake-quant variants for the embed PTQ study.
New schemes live here so existing ``rtn_quant`` / ``wrap_model`` stay untouched.
Supports W3+ RTN (including bits not allowed by ``rtn_quantize``), frequency-aware
mixed precision, and product quantization. All wrappers keep an fp32 master and
apply fake-quant only in forward; tied ``lm_head`` shares the same master.
"""
from __future__ import annotations
from collections.abc import Iterable, Sequence
from typing import Any
import torch
import torch.nn as nn
import torch.nn.functional as F
from .rtn_quant import rtn_quantize
def embed_storage_bits(
*,
vocab: int,
dim: int,
scheme: str,
hot_tokens: int = 32_768,
group_size: int = 64,
pq_subvector: int = 8,
pq_codebook_size: int = 256,
scale_bits: int = 16,
codebook_elem_bits: int = 16,
) -> dict[str, float]:
"""Report effective bits/param and packed size in MB for one tied matrix."""
params = float(vocab * dim)
if scheme == "bf16":
total_bits = params * 16.0
elif scheme == "w4_rtn_per_channel":
total_bits = params * 4.0 + vocab * scale_bits
elif scheme == "w3_rtn_group":
groups = (dim + group_size - 1) // group_size
total_bits = params * 3.0 + vocab * groups * scale_bits
elif scheme == "freq_mixed":
hot = min(hot_tokens, vocab)
cold = vocab - hot
cold_groups = (dim + group_size - 1) // group_size
total_bits = (
hot * dim * 4.0
+ hot * scale_bits
+ cold * dim * 2.0
+ cold * cold_groups * scale_bits
)
elif scheme == "pq":
if dim % pq_subvector:
raise ValueError(f"dim {dim} must be divisible by pq_subvector {pq_subvector}")
n_sub = dim // pq_subvector
index_bits = vocab * n_sub * 8.0
codebook_bits = n_sub * pq_codebook_size * pq_subvector * codebook_elem_bits
total_bits = index_bits + codebook_bits
else:
raise ValueError(f"unknown scheme: {scheme}")
return {
"params": params,
"total_bits": total_bits,
"bits": total_bits / params,
"mb": total_bits / 8.0 / 1_000_000.0,
}
def _validate_extended_rtn(
weight: torch.Tensor,
bits: int,
granularity: str,
group_size: int,
) -> None:
if weight.ndim != 2:
raise ValueError(f"RTN only supports 2-D weights, got {weight.ndim}-D")
if not weight.is_floating_point():
raise ValueError("RTN fake quantization requires a floating-point weight")
if bits not in {2, 3, 4, 8}:
raise ValueError("bits must be one of {2, 3, 4, 8}")
if granularity not in {"per_channel", "per_group"}:
raise ValueError("granularity must be 'per_channel' or 'per_group'")
if not isinstance(group_size, int) or group_size <= 0:
raise ValueError("group_size must be a positive integer")
def extended_rtn_quantize(
weight: torch.Tensor,
bits: int = 4,
granularity: str = "per_channel",
group_size: int = 64,
) -> torch.Tensor:
"""Symmetric absmax RTN supporting W2/W3 (and W4/W8).
W4/W8 with the same args as ``rtn_quantize`` delegates to that helper so the
study baseline matches the existing quantlib grid exactly.
"""
_validate_extended_rtn(weight, bits, granularity, group_size)
if bits in {4, 8}:
return rtn_quantize(
weight, bits=bits, granularity=granularity, group_size=group_size
)
rows, columns = weight.shape
actual_group_size = columns if granularity == "per_channel" else group_size
number_of_groups = (columns + actual_group_size - 1) // actual_group_size
padded_columns = number_of_groups * actual_group_size
groups = weight
if padded_columns != columns:
groups = F.pad(groups, (0, padded_columns - columns))
groups = groups.reshape(rows, number_of_groups, actual_group_size)
qmax = 2 ** (bits - 1) - 1
scale_dtype = (
weight.dtype
if weight.dtype in {torch.float16, torch.bfloat16}
else torch.float32
)
scales = groups.float().abs().amax(dim=-1, keepdim=True) / qmax
scales = scales.to(scale_dtype).float()
safe_scales = torch.where(scales == 0, torch.ones_like(scales), scales)
integers = torch.round(groups.float() / safe_scales).clamp(-qmax, qmax)
quantized = (integers * scales).reshape(rows, -1)[:, :columns]
return quantized.to(weight.dtype)
class _ExtendedRTNSTE(torch.autograd.Function):
@staticmethod
def forward(
ctx: Any,
weight: torch.Tensor,
bits: int,
granularity: str,
group_size: int,
) -> torch.Tensor:
del ctx
return extended_rtn_quantize(weight, bits, granularity, group_size)
@staticmethod
def backward(
ctx: Any, grad_output: torch.Tensor
) -> tuple[torch.Tensor, None, None, None]:
del ctx
return grad_output, None, None, None
def extended_rtn_quantize_ste(
weight: torch.Tensor,
bits: int = 4,
granularity: str = "per_channel",
group_size: int = 64,
) -> torch.Tensor:
return _ExtendedRTNSTE.apply(weight, bits, granularity, group_size)
def find_embed_tokens(model: nn.Module) -> tuple[str, nn.Embedding]:
"""Locate the LLM ``embed_tokens`` module (prefer language_model path)."""
candidates: list[tuple[str, nn.Embedding]] = []
for name, module in model.named_modules():
if isinstance(module, nn.Embedding) and name.endswith("embed_tokens"):
candidates.append((name, module))
if not candidates:
raise RuntimeError("could not find embed_tokens Embedding on model")
for name, module in candidates:
if "language_model" in name:
return name, module
return candidates[0]
def _set_module_by_name(root: nn.Module, dotted: str, value: nn.Module) -> None:
parent_path, _, leaf = dotted.rpartition(".")
parent = root.get_submodule(parent_path) if parent_path else root
setattr(parent, leaf, value)
class SharedQuantLMHead(nn.Module):
"""lm_head that matmuls against a sibling embedding's fake-quant weight.
The embedding is stored as a plain attribute (not a registered submodule) so
the tied module is not duplicated in ``named_modules`` / ``state_dict``.
"""
def __init__(self, embedding: nn.Module, bias: torch.Tensor | None = None) -> None:
super().__init__()
object.__setattr__(self, "_quant_embed", embedding)
if bias is not None:
self.bias = nn.Parameter(bias.detach().float().clone())
else:
self.register_parameter("bias", None)
def forward(self, hidden: torch.Tensor) -> torch.Tensor:
weight = self._quant_embed.quantized_weight().to(dtype=hidden.dtype)
bias = self.bias.to(hidden.dtype) if self.bias is not None else None
return F.linear(hidden, weight, bias)
def install_tied_embed_quant(model: nn.Module, embedding: nn.Module) -> dict[str, str]:
"""Replace ``embed_tokens`` and retie ``lm_head`` to the same fake-quant module."""
embed_path, _old = find_embed_tokens(model)
_set_module_by_name(model, embed_path, embedding)
if not hasattr(model, "lm_head"):
raise RuntimeError("model has no lm_head to retie")
old_head = model.lm_head
bias = old_head.bias.detach().clone() if getattr(old_head, "bias", None) is not None else None
model.lm_head = SharedQuantLMHead(embedding, bias=bias)
return {"embed_tokens": embed_path, "lm_head": "lm_head"}
def enable_tied_embed_eval_cache(model: nn.Module) -> None:
"""Cache fake-quant embed matrix once (required for fast autoregressive eval)."""
for name, module in model.named_modules():
if not name.endswith("embed_tokens"):
continue
if hasattr(module, "enable_eval_cache"):
module.enable_eval_cache()
return
if hasattr(module, "quantized_weight"):
module.quantized_weight()
return
raise RuntimeError("no embed_tokens module found for eval cache")
class ConfigurableRTNEmbedding(nn.Embedding):
"""Embedding with configurable RTN fake-quant (W2/W3/W4/W8)."""
def __init__(
self,
num_embeddings: int,
embedding_dim: int,
padding_idx: int | None = None,
max_norm: float | None = None,
norm_type: float = 2.0,
scale_grad_by_freq: bool = False,
sparse: bool = False,
*,
bits: int = 4,
granularity: str = "per_channel",
group_size: int = 64,
device: torch.device | str | None = None,
) -> None:
_validate_extended_rtn(
torch.empty(num_embeddings, embedding_dim), bits, granularity, group_size
)
super().__init__(
num_embeddings,
embedding_dim,
padding_idx=padding_idx,
max_norm=max_norm,
norm_type=norm_type,
scale_grad_by_freq=scale_grad_by_freq,
sparse=sparse,
device=device,
dtype=torch.float32,
)
self.bits = bits
self.granularity = granularity
self.group_size = group_size
self._eval_cache: torch.Tensor | None = None
@classmethod
def from_embedding(
cls,
embedding: nn.Embedding,
*,
bits: int = 4,
granularity: str = "per_channel",
group_size: int = 64,
) -> "ConfigurableRTNEmbedding":
converted = cls(
embedding.num_embeddings,
embedding.embedding_dim,
padding_idx=embedding.padding_idx,
max_norm=embedding.max_norm,
norm_type=embedding.norm_type,
scale_grad_by_freq=embedding.scale_grad_by_freq,
sparse=embedding.sparse,
bits=bits,
granularity=granularity,
group_size=group_size,
device=embedding.weight.device,
)
with torch.no_grad():
converted.weight.copy_(embedding.weight.detach().float())
converted.weight.requires_grad_(embedding.weight.requires_grad)
converted.train(embedding.training)
return converted
def enable_eval_cache(self) -> torch.Tensor:
"""Materialize fake-quant weights once for PTQ / generation."""
with torch.no_grad():
cached = extended_rtn_quantize(
self.weight,
bits=self.bits,
granularity=self.granularity,
group_size=self.group_size,
).detach()
self._eval_cache = cached
return cached
def clear_eval_cache(self) -> None:
self._eval_cache = None
def quantized_weight(self) -> torch.Tensor:
if self._eval_cache is not None:
return self._eval_cache
return extended_rtn_quantize_ste(
self.weight,
bits=self.bits,
granularity=self.granularity,
group_size=self.group_size,
)
def forward(self, input: torch.Tensor) -> torch.Tensor:
return F.embedding(
input,
self.quantized_weight(),
self.padding_idx,
self.max_norm,
self.norm_type,
self.scale_grad_by_freq,
self.sparse,
)
def count_token_frequencies(
texts: Iterable[str],
tokenizer,
*,
vocab_size: int,
) -> torch.Tensor:
"""Return int64 counts ``[vocab_size]`` from raw transcript strings."""
counts = torch.zeros(vocab_size, dtype=torch.int64)
batch: list[int] = []
flush_every = 1 << 16
def flush() -> None:
nonlocal batch
if not batch:
return
flat = torch.tensor(batch, dtype=torch.int64)
flat = flat[(flat >= 0) & (flat < vocab_size)]
if flat.numel():
counts.scatter_add_(0, flat, torch.ones_like(flat))
batch = []
for text in texts:
if not text:
continue
ids = tokenizer(text, add_special_tokens=False)["input_ids"]
if ids:
batch.extend(ids)
if len(batch) >= flush_every:
flush()
flush()
return counts
def topk_token_mask(counts: torch.Tensor, k: int) -> torch.Tensor:
"""Boolean mask over vocab rows marked as frequent (True = hot / W4)."""
vocab = counts.numel()
k = min(int(k), vocab)
if k <= 0:
return torch.zeros(vocab, dtype=torch.bool, device=counts.device)
# Stable: break ties by lower token id so tests are deterministic.
order = torch.argsort(
counts.float()
+ (vocab - torch.arange(vocab, device=counts.device)).float() * 1e-12,
descending=True,
)
mask = torch.zeros(vocab, dtype=torch.bool, device=counts.device)
mask[order[:k]] = True
return mask
class FreqMixedEmbedding(nn.Embedding):
"""Hot rows W4 per-channel; cold rows W2 group-quantized."""
def __init__(
self,
num_embeddings: int,
embedding_dim: int,
padding_idx: int | None = None,
max_norm: float | None = None,
norm_type: float = 2.0,
scale_grad_by_freq: bool = False,
sparse: bool = False,
*,
hot_bits: int = 4,
cold_bits: int = 2,
cold_group_size: int = 64,
device: torch.device | str | None = None,
) -> None:
super().__init__(
num_embeddings,
embedding_dim,
padding_idx=padding_idx,
max_norm=max_norm,
norm_type=norm_type,
scale_grad_by_freq=scale_grad_by_freq,
sparse=sparse,
device=device,
dtype=torch.float32,
)
self.hot_bits = hot_bits
self.cold_bits = cold_bits
self.cold_group_size = cold_group_size
self._eval_cache: torch.Tensor | None = None
self.register_buffer(
"hot_mask",
torch.zeros(num_embeddings, dtype=torch.bool, device=device),
persistent=True,
)
@classmethod
def from_embedding(
cls,
embedding: nn.Embedding,
hot_mask: torch.Tensor,
*,
hot_bits: int = 4,
cold_bits: int = 2,
cold_group_size: int = 64,
) -> "FreqMixedEmbedding":
if hot_mask.shape != (embedding.num_embeddings,):
raise ValueError(
f"hot_mask shape {tuple(hot_mask.shape)} != ({embedding.num_embeddings},)"
)
converted = cls(
embedding.num_embeddings,
embedding.embedding_dim,
padding_idx=embedding.padding_idx,
max_norm=embedding.max_norm,
norm_type=embedding.norm_type,
scale_grad_by_freq=embedding.scale_grad_by_freq,
sparse=embedding.sparse,
hot_bits=hot_bits,
cold_bits=cold_bits,
cold_group_size=cold_group_size,
device=embedding.weight.device,
)
with torch.no_grad():
converted.weight.copy_(embedding.weight.detach().float())
converted.hot_mask.copy_(hot_mask.to(device=converted.hot_mask.device))
converted.weight.requires_grad_(embedding.weight.requires_grad)
converted.train(embedding.training)
return converted
def enable_eval_cache(self) -> torch.Tensor:
with torch.no_grad():
hot = extended_rtn_quantize(
self.weight,
bits=self.hot_bits,
granularity="per_channel",
group_size=self.weight.shape[1],
)
cold = extended_rtn_quantize(
self.weight,
bits=self.cold_bits,
granularity="per_group",
group_size=self.cold_group_size,
)
cached = torch.where(self.hot_mask.unsqueeze(-1), hot, cold).detach()
self._eval_cache = cached
return cached
def clear_eval_cache(self) -> None:
self._eval_cache = None
def quantized_weight(self) -> torch.Tensor:
if self._eval_cache is not None:
return self._eval_cache
hot = extended_rtn_quantize_ste(
self.weight,
bits=self.hot_bits,
granularity="per_channel",
group_size=self.weight.shape[1],
)
cold = extended_rtn_quantize_ste(
self.weight,
bits=self.cold_bits,
granularity="per_group",
group_size=self.cold_group_size,
)
mask = self.hot_mask.unsqueeze(-1)
return torch.where(mask, hot, cold)
def forward(self, input: torch.Tensor) -> torch.Tensor:
return F.embedding(
input,
self.quantized_weight(),
self.padding_idx,
self.max_norm,
self.norm_type,
self.scale_grad_by_freq,
self.sparse,
)
@torch.no_grad()
def _kmeans_torch(
points: torch.Tensor,
k: int,
*,
iters: int = 15,
seed: int = 0,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Lloyd k-means on ``[N, D]``; returns ``(centroids [K,D], labels [N])``."""
if points.ndim != 2:
raise ValueError("points must be 2-D")
n, dim = points.shape
del dim
if n == 0:
raise ValueError("empty points for k-means")
k = min(k, n)
generator = torch.Generator(device=points.device)
generator.manual_seed(seed)
perm = torch.randperm(n, generator=generator, device=points.device)
centroids = points[perm[:k]].clone()
labels = torch.zeros(n, dtype=torch.int64, device=points.device)
flat = points.float()
for _ in range(iters):
chunk = max(1, min(n, 8192))
label_chunks: list[torch.Tensor] = []
for start in range(0, n, chunk):
block = flat[start : start + chunk]
dist = torch.cdist(block, centroids.float(), p=2)
label_chunks.append(dist.argmin(dim=1))
labels = torch.cat(label_chunks, dim=0)
for center_id in range(k):
members = flat[labels == center_id]
if members.numel() == 0:
idx = int(
torch.randint(
0, n, (1,), generator=generator, device=points.device
).item()
)
centroids[center_id] = flat[idx]
else:
centroids[center_id] = members.mean(dim=0)
return centroids.to(points.dtype), labels
class PQEmbedding(nn.Module):
"""Product-quantization fake embedding: M×(K×d) codebooks + uint8 indices."""
def __init__(
self,
num_embeddings: int,
embedding_dim: int,
*,
subvector_dim: int = 8,
codebook_size: int = 256,
padding_idx: int | None = None,
device: torch.device | str | None = None,
) -> None:
super().__init__()
if embedding_dim % subvector_dim:
raise ValueError(
f"embedding_dim ({embedding_dim}) must be divisible by "
f"subvector_dim ({subvector_dim})"
)
self.num_embeddings = num_embeddings
self.embedding_dim = embedding_dim
self.subvector_dim = subvector_dim
self.codebook_size = codebook_size
self.n_subvectors = embedding_dim // subvector_dim
self.padding_idx = padding_idx
self.weight = nn.Parameter(
torch.empty(num_embeddings, embedding_dim, device=device, dtype=torch.float32),
requires_grad=False,
)
self.register_buffer(
"codebooks",
torch.zeros(
self.n_subvectors,
codebook_size,
subvector_dim,
device=device,
dtype=torch.float32,
),
persistent=True,
)
self.register_buffer(
"codes",
torch.zeros(num_embeddings, self.n_subvectors, device=device, dtype=torch.uint8),
persistent=True,
)
self.register_buffer(
"_reconstructed",
torch.zeros(num_embeddings, embedding_dim, device=device, dtype=torch.float32),
persistent=False,
)
self._cache_valid = False
@classmethod
def from_embedding(
cls,
embedding: nn.Embedding,
*,
subvector_dim: int = 8,
codebook_size: int = 256,
sample_frac: float = 0.2,
kmeans_iters: int = 15,
seed: int = 0,
device: torch.device | str | None = None,
) -> "PQEmbedding":
target_device = torch.device(device) if device is not None else embedding.weight.device
converted = cls(
embedding.num_embeddings,
embedding.embedding_dim,
subvector_dim=subvector_dim,
codebook_size=codebook_size,
padding_idx=embedding.padding_idx,
device=target_device,
)
with torch.no_grad():
converted.weight.copy_(embedding.weight.detach().float().to(target_device))
converted.fit(
sample_frac=sample_frac,
kmeans_iters=kmeans_iters,
seed=seed,
)
return converted
@torch.no_grad()
def fit(
self,
*,
sample_frac: float = 0.2,
kmeans_iters: int = 15,
seed: int = 0,
) -> None:
weight = self.weight.detach()
vocab, _dim = weight.shape
n_sub = self.n_subvectors
d = self.subvector_dim
k = self.codebook_size
sample_n = max(k, min(vocab, int(round(vocab * sample_frac))))
generator = torch.Generator(device=weight.device)
generator.manual_seed(seed)
sample_idx = torch.randperm(vocab, generator=generator, device=weight.device)[
:sample_n
]
sampled = weight[sample_idx].reshape(sample_n, n_sub, d)
codebooks = torch.empty_like(self.codebooks)
for sub in range(n_sub):
points = sampled[:, sub, :].contiguous()
centroids, _ = _kmeans_torch(
points, k, iters=kmeans_iters, seed=seed + sub
)
if centroids.shape[0] < k:
padded = torch.zeros(k, d, device=weight.device, dtype=weight.dtype)
padded[: centroids.shape[0]] = centroids
centroids = padded
codebooks[sub] = centroids
self.codebooks.copy_(codebooks)
codes = torch.empty(vocab, n_sub, device=weight.device, dtype=torch.int64)
reshaped = weight.reshape(vocab, n_sub, d)
chunk = max(1, min(vocab, 4096))
for sub in range(n_sub):
centroids = codebooks[sub].float()
label_chunks: list[torch.Tensor] = []
for start in range(0, vocab, chunk):
block = reshaped[start : start + chunk, sub, :].float()
dist = torch.cdist(block, centroids, p=2)
label_chunks.append(dist.argmin(dim=1))
codes[:, sub] = torch.cat(label_chunks, dim=0)
self.codes.copy_(codes.to(torch.uint8))
self._cache_valid = False
self._refresh_cache()
@torch.no_grad()
def _refresh_cache(self) -> None:
vocab = self.num_embeddings
n_sub = self.n_subvectors
d = self.subvector_dim
reconstructed = torch.empty(
vocab, n_sub, d, device=self.codebooks.device, dtype=self.codebooks.dtype
)
codes = self.codes.long()
for sub in range(n_sub):
reconstructed[:, sub, :] = self.codebooks[sub][codes[:, sub]]
self._reconstructed.copy_(reconstructed.reshape(vocab, self.embedding_dim))
self._cache_valid = True
def enable_eval_cache(self) -> torch.Tensor:
self._refresh_cache()
return self._reconstructed
def quantized_weight(self) -> torch.Tensor:
if not self._cache_valid:
self._refresh_cache()
return self._reconstructed
def forward(self, input: torch.Tensor) -> torch.Tensor:
return F.embedding(input, self.quantized_weight(), self.padding_idx)
def apply_scheme_a(model: nn.Module) -> dict[str, Any]:
"""W4 RTN per-channel baseline on the tied embed matrix."""
_path, embed = find_embed_tokens(model)
quantized = ConfigurableRTNEmbedding.from_embedding(
embed, bits=4, granularity="per_channel", group_size=embed.embedding_dim
)
paths = install_tied_embed_quant(model, quantized)
stats = embed_storage_bits(
vocab=quantized.num_embeddings,
dim=quantized.embedding_dim,
scheme="w4_rtn_per_channel",
)
return {"name": "A_w4", "paths": paths, **stats}
def apply_scheme_b(model: nn.Module, *, group_size: int = 64) -> dict[str, Any]:
"""W3 RTN group64 on the tied embed matrix."""
_path, embed = find_embed_tokens(model)
quantized = ConfigurableRTNEmbedding.from_embedding(
embed, bits=3, granularity="per_group", group_size=group_size
)
paths = install_tied_embed_quant(model, quantized)
stats = embed_storage_bits(
vocab=quantized.num_embeddings,
dim=quantized.embedding_dim,
scheme="w3_rtn_group",
group_size=group_size,
)
return {"name": "B_w3", "paths": paths, **stats}
def apply_scheme_c(
model: nn.Module,
hot_mask: torch.Tensor,
*,
cold_group_size: int = 64,
) -> dict[str, Any]:
"""Frequency-aware mixed W4/W2 on the tied embed matrix."""
_path, embed = find_embed_tokens(model)
quantized = FreqMixedEmbedding.from_embedding(
embed, hot_mask=hot_mask.to(embed.weight.device), cold_group_size=cold_group_size
)
paths = install_tied_embed_quant(model, quantized)
stats = embed_storage_bits(
vocab=quantized.num_embeddings,
dim=quantized.embedding_dim,
scheme="freq_mixed",
hot_tokens=int(hot_mask.sum().item()),
group_size=cold_group_size,
)
return {"name": "C_freq", "paths": paths, **stats}
def apply_scheme_d(
model: nn.Module,
*,
sample_frac: float = 0.2,
device: torch.device | str | None = None,
) -> dict[str, Any]:
"""PQ 8-dim × 256 codebooks on the tied embed matrix."""
_path, embed = find_embed_tokens(model)
quantized = PQEmbedding.from_embedding(
embed, sample_frac=sample_frac, device=device or embed.weight.device
)
paths = install_tied_embed_quant(model, quantized)
stats = embed_storage_bits(
vocab=quantized.num_embeddings,
dim=quantized.embedding_dim,
scheme="pq",
)
return {"name": "D_pq", "paths": paths, **stats}
NINE_LANG_TRAIN_MANIFESTS: tuple[str, ...] = (
"fleurs-zh-train",
"fleurs-en-train",
"fleurs-ja-train",
"fleurs-ko-train",
"fleurs-de-train",
"fleurs-es-train",
"fleurs-fr-train",
"fleurs-it-train",
"fleurs-ru-train",
)
def load_manifest_texts(manifest_paths: Sequence[Any]) -> list[str]:
from datapipe.io import read_jsonl
texts: list[str] = []
for path in manifest_paths:
for row in read_jsonl(path):
text = row.get("text") or row.get("target") or ""
if text:
texts.append(str(text))
return texts