pplx-context-mlx 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pplx_context_mlx/__init__.py +5 -0
- pplx_context_mlx/checkpoint.py +55 -0
- pplx_context_mlx/cli.py +23 -0
- pplx_context_mlx/config.py +113 -0
- pplx_context_mlx/engine.py +119 -0
- pplx_context_mlx/gated_delta.py +651 -0
- pplx_context_mlx/images.py +176 -0
- pplx_context_mlx/model.py +73 -0
- pplx_context_mlx/text.py +233 -0
- pplx_context_mlx/tokenizer.py +83 -0
- pplx_context_mlx/vision.py +167 -0
- pplx_context_mlx-0.1.0.dist-info/METADATA +199 -0
- pplx_context_mlx-0.1.0.dist-info/RECORD +18 -0
- pplx_context_mlx-0.1.0.dist-info/WHEEL +5 -0
- pplx_context_mlx-0.1.0.dist-info/entry_points.txt +2 -0
- pplx_context_mlx-0.1.0.dist-info/licenses/LICENSE +21 -0
- pplx_context_mlx-0.1.0.dist-info/licenses/NOTICE +346 -0
- pplx_context_mlx-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from .checkpoint import CHECKPOINT_0_6B, REVISION_0_6B, resolve_checkpoint
|
|
2
|
+
from .engine import ContextualEngine, load_model
|
|
3
|
+
from .tokenizer import ContextualTokenizer
|
|
4
|
+
|
|
5
|
+
__all__ = ["CHECKPOINT_0_6B", "REVISION_0_6B", "ContextualEngine", "ContextualTokenizer", "load_model", "resolve_checkpoint"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import mlx.core as mx
|
|
7
|
+
from huggingface_hub import snapshot_download
|
|
8
|
+
|
|
9
|
+
from .config import ModelConfig
|
|
10
|
+
from .model import PplxContextualModel
|
|
11
|
+
|
|
12
|
+
CHECKPOINT_0_6B = "perplexity-ai/pplx-embed-v2-context-0.6b"
|
|
13
|
+
REVISION_0_6B = "6d1fddab0a18478acc0a51edd1e81333fe441095"
|
|
14
|
+
_FILES = ["config.json", "tokenizer.json", "model.safetensors.index.json", "*.safetensors"]
|
|
15
|
+
_DTYPES = {"float32": mx.float32, "float16": mx.float16}
|
|
16
|
+
_KEEP_FP32 = ("contextual_projection.",)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_checkpoint(path_or_repo: str, revision: str | None = None) -> Path:
|
|
20
|
+
local = Path(path_or_repo).expanduser()
|
|
21
|
+
if local.is_dir():
|
|
22
|
+
return local
|
|
23
|
+
if path_or_repo == CHECKPOINT_0_6B and revision is None:
|
|
24
|
+
revision = REVISION_0_6B
|
|
25
|
+
return Path(snapshot_download(path_or_repo, revision=revision, allow_patterns=_FILES))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_weights(path: Path, dtype: mx.Dtype) -> dict[str, mx.array]:
|
|
29
|
+
weight_map = json.loads((path / "model.safetensors.index.json").read_text())["weight_map"]
|
|
30
|
+
weights: dict[str, mx.array] = {}
|
|
31
|
+
for filename in sorted(set(weight_map.values())):
|
|
32
|
+
tensors = mx.load(str(path / filename))
|
|
33
|
+
if not isinstance(tensors, dict):
|
|
34
|
+
raise TypeError(f"Expected named tensors in {filename}")
|
|
35
|
+
for key, value in tensors.items():
|
|
36
|
+
weights[key] = value if key.startswith(_KEEP_FP32) else value.astype(dtype)
|
|
37
|
+
missing = set(weight_map) - set(weights)
|
|
38
|
+
if missing:
|
|
39
|
+
raise ValueError(f"Checkpoint is missing tensors: {sorted(missing)[:5]}")
|
|
40
|
+
return weights
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_dtype(dtype: str) -> None:
|
|
44
|
+
if dtype not in _DTYPES:
|
|
45
|
+
raise ValueError(f"Unsupported dtype {dtype!r}; choose from {sorted(_DTYPES)}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_checkpoint(path: Path, dtype: str = "float32") -> tuple[PplxContextualModel, ModelConfig]:
|
|
49
|
+
validate_dtype(dtype)
|
|
50
|
+
config = ModelConfig.from_dict(json.loads((path / "config.json").read_text()))
|
|
51
|
+
model = PplxContextualModel(config)
|
|
52
|
+
model.load_weights(list(load_weights(path, _DTYPES[dtype]).items()), strict=True)
|
|
53
|
+
model.eval()
|
|
54
|
+
mx.eval(model.parameters())
|
|
55
|
+
return model, config
|
pplx_context_mlx/cli.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .checkpoint import CHECKPOINT_0_6B
|
|
9
|
+
from .engine import load_model
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
parser = argparse.ArgumentParser(description="Embed a JSON list of documents, each a list of chunk strings.")
|
|
14
|
+
parser.add_argument("input", help="JSON file, or - for stdin")
|
|
15
|
+
parser.add_argument("--task", choices=["document", "query"], default="document")
|
|
16
|
+
parser.add_argument("--model", default=CHECKPOINT_0_6B)
|
|
17
|
+
parser.add_argument("--dtype", default="float32")
|
|
18
|
+
parser.add_argument("--normalize", action="store_true")
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
rows = json.loads(sys.stdin.read() if args.input == "-" else Path(args.input).read_text(encoding="utf-8"))
|
|
21
|
+
engine = load_model(args.model, dtype=args.dtype)
|
|
22
|
+
encode = engine.encode_queries if args.task == "query" else engine.encode
|
|
23
|
+
json.dump([row.tolist() for row in encode(rows, normalize_embeddings=args.normalize)], sys.stdout)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class TextConfig:
|
|
9
|
+
vocab_size: int
|
|
10
|
+
hidden_size: int
|
|
11
|
+
intermediate_size: int
|
|
12
|
+
layer_types: tuple[str, ...]
|
|
13
|
+
head_dim: int
|
|
14
|
+
num_attention_heads: int
|
|
15
|
+
num_key_value_heads: int
|
|
16
|
+
linear_conv_kernel_dim: int
|
|
17
|
+
linear_key_head_dim: int
|
|
18
|
+
linear_num_key_heads: int
|
|
19
|
+
linear_value_head_dim: int
|
|
20
|
+
linear_num_value_heads: int
|
|
21
|
+
rms_norm_eps: float
|
|
22
|
+
rope_theta: float
|
|
23
|
+
partial_rotary_factor: float
|
|
24
|
+
mrope_section: tuple[int, ...]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class VisionConfig:
|
|
29
|
+
depth: int
|
|
30
|
+
hidden_size: int
|
|
31
|
+
intermediate_size: int
|
|
32
|
+
in_channels: int
|
|
33
|
+
num_heads: int
|
|
34
|
+
num_position_embeddings: int
|
|
35
|
+
out_hidden_size: int
|
|
36
|
+
patch_size: int
|
|
37
|
+
spatial_merge_size: int
|
|
38
|
+
temporal_patch_size: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ModelConfig:
|
|
43
|
+
text: TextConfig
|
|
44
|
+
vision: VisionConfig
|
|
45
|
+
image_token_id: int
|
|
46
|
+
vision_start_token_id: int
|
|
47
|
+
vision_end_token_id: int
|
|
48
|
+
embedding_dim: int
|
|
49
|
+
query_prefix: str
|
|
50
|
+
document_prefix: str
|
|
51
|
+
boundary_marker: str
|
|
52
|
+
query_length: int
|
|
53
|
+
document_length: int
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, raw: dict[str, Any]) -> ModelConfig:
|
|
57
|
+
if raw.get("model_type") != "pplx_contextual_qwen3_5":
|
|
58
|
+
raise ValueError(f"Expected a pplx_contextual_qwen3_5 checkpoint, got {raw.get('model_type')!r}")
|
|
59
|
+
text = raw["text_config"]
|
|
60
|
+
if text.get("is_causal", True):
|
|
61
|
+
raise ValueError("Expected a bidirectional checkpoint (text_config.is_causal = false)")
|
|
62
|
+
kinds = set(text["layer_types"])
|
|
63
|
+
if not kinds <= {"linear_attention", "full_attention"}:
|
|
64
|
+
raise ValueError(f"Unsupported layer types: {sorted(kinds)}")
|
|
65
|
+
if text.get("mlp_only_layers"):
|
|
66
|
+
raise ValueError("MLP-only layers are not supported")
|
|
67
|
+
rope = text["rope_parameters"]
|
|
68
|
+
if rope.get("rope_type") != "default" or not rope.get("mrope_interleaved"):
|
|
69
|
+
raise ValueError(f"Unsupported rope parameters: {rope}")
|
|
70
|
+
vision = raw["vision_config"]
|
|
71
|
+
if vision.get("deepstack_visual_indexes"):
|
|
72
|
+
raise ValueError("Deepstack visual features are not supported")
|
|
73
|
+
return cls(
|
|
74
|
+
vision=VisionConfig(
|
|
75
|
+
depth=vision["depth"],
|
|
76
|
+
hidden_size=vision["hidden_size"],
|
|
77
|
+
intermediate_size=vision["intermediate_size"],
|
|
78
|
+
in_channels=vision["in_channels"],
|
|
79
|
+
num_heads=vision["num_heads"],
|
|
80
|
+
num_position_embeddings=vision["num_position_embeddings"],
|
|
81
|
+
out_hidden_size=vision["out_hidden_size"],
|
|
82
|
+
patch_size=vision["patch_size"],
|
|
83
|
+
spatial_merge_size=vision["spatial_merge_size"],
|
|
84
|
+
temporal_patch_size=vision["temporal_patch_size"],
|
|
85
|
+
),
|
|
86
|
+
image_token_id=raw["image_token_id"],
|
|
87
|
+
vision_start_token_id=raw["vision_start_token_id"],
|
|
88
|
+
vision_end_token_id=raw["vision_end_token_id"],
|
|
89
|
+
text=TextConfig(
|
|
90
|
+
vocab_size=text["vocab_size"],
|
|
91
|
+
hidden_size=text["hidden_size"],
|
|
92
|
+
intermediate_size=text["intermediate_size"],
|
|
93
|
+
layer_types=tuple(text["layer_types"]),
|
|
94
|
+
head_dim=text["head_dim"],
|
|
95
|
+
num_attention_heads=text["num_attention_heads"],
|
|
96
|
+
num_key_value_heads=text["num_key_value_heads"],
|
|
97
|
+
linear_conv_kernel_dim=text["linear_conv_kernel_dim"],
|
|
98
|
+
linear_key_head_dim=text["linear_key_head_dim"],
|
|
99
|
+
linear_num_key_heads=text["linear_num_key_heads"],
|
|
100
|
+
linear_value_head_dim=text["linear_value_head_dim"],
|
|
101
|
+
linear_num_value_heads=text["linear_num_value_heads"],
|
|
102
|
+
rms_norm_eps=text["rms_norm_eps"],
|
|
103
|
+
rope_theta=rope["rope_theta"],
|
|
104
|
+
partial_rotary_factor=rope["partial_rotary_factor"],
|
|
105
|
+
mrope_section=tuple(rope["mrope_section"]),
|
|
106
|
+
),
|
|
107
|
+
embedding_dim=raw["embedding_dim"],
|
|
108
|
+
query_prefix=raw["query_prefix"],
|
|
109
|
+
document_prefix=raw["document_prefix"],
|
|
110
|
+
boundary_marker=raw["boundary_marker"],
|
|
111
|
+
query_length=raw["query_length"],
|
|
112
|
+
document_length=raw["document_length"],
|
|
113
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mlx.core as mx
|
|
4
|
+
import numpy as np
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
from .checkpoint import (
|
|
8
|
+
CHECKPOINT_0_6B,
|
|
9
|
+
load_checkpoint,
|
|
10
|
+
resolve_checkpoint,
|
|
11
|
+
validate_dtype,
|
|
12
|
+
)
|
|
13
|
+
from .config import ModelConfig
|
|
14
|
+
from .images import prepare_images
|
|
15
|
+
from .model import PplxContextualModel, quantize_int8
|
|
16
|
+
from .tokenizer import ContextualTokenizer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate(documents: list[list[str]], batch_size: int) -> None:
|
|
20
|
+
if not isinstance(documents, list) or any(
|
|
21
|
+
not isinstance(row, list) or any(not isinstance(text, str) for text in row) for row in documents
|
|
22
|
+
):
|
|
23
|
+
raise TypeError("Expected a list of documents, each a list of text chunks")
|
|
24
|
+
if batch_size < 1:
|
|
25
|
+
raise ValueError("batch_size must be positive")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _normalize(vectors: mx.array) -> mx.array:
|
|
29
|
+
return vectors / mx.maximum(mx.linalg.norm(vectors, axis=-1, keepdims=True), 1e-12)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ContextualEngine:
|
|
33
|
+
"""Same call shape as upstream: one (chunk_count, embedding_dim) array per document."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, model: PplxContextualModel, tokenizer: ContextualTokenizer, config: ModelConfig):
|
|
36
|
+
self.model = model
|
|
37
|
+
self.tokenizer = tokenizer
|
|
38
|
+
self.config = config
|
|
39
|
+
|
|
40
|
+
def project_documents(self, documents: list[list[str]]) -> mx.array:
|
|
41
|
+
features = self.tokenizer.encode_documents(documents)
|
|
42
|
+
return self.model.embed_documents(
|
|
43
|
+
mx.array(features["input_ids"]),
|
|
44
|
+
mx.array(features["attention_mask"]),
|
|
45
|
+
mx.array(features["chunk_token_spans"]),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def project_queries(self, queries: list[list[str]]) -> mx.array:
|
|
49
|
+
features = self.tokenizer.encode_queries(queries)
|
|
50
|
+
return self.model.embed_queries(mx.array(features["input_ids"]), mx.array(features["attention_mask"]))
|
|
51
|
+
|
|
52
|
+
def _encode(self, rows: list[list[str]], project, batch_size: int, normalize_embeddings: bool) -> list[np.ndarray]:
|
|
53
|
+
_validate(rows, batch_size)
|
|
54
|
+
output: list[np.ndarray] = []
|
|
55
|
+
for start in range(0, len(rows), batch_size):
|
|
56
|
+
batch = rows[start : start + batch_size]
|
|
57
|
+
vectors = quantize_int8(project(batch))
|
|
58
|
+
if normalize_embeddings:
|
|
59
|
+
vectors = _normalize(vectors)
|
|
60
|
+
mx.eval(vectors)
|
|
61
|
+
matrix = np.array(vectors, dtype=np.float32, copy=True)
|
|
62
|
+
output.extend(matrix[index, : len(chunks)] for index, chunks in enumerate(batch))
|
|
63
|
+
return output
|
|
64
|
+
|
|
65
|
+
def encode(
|
|
66
|
+
self, documents: list[list[str]], batch_size: int = 32, *, normalize_embeddings: bool = False
|
|
67
|
+
) -> list[np.ndarray]:
|
|
68
|
+
return self._encode(documents, self.project_documents, batch_size, normalize_embeddings)
|
|
69
|
+
|
|
70
|
+
def encode_queries(
|
|
71
|
+
self, queries: list[list[str]], batch_size: int = 32, *, normalize_embeddings: bool = False
|
|
72
|
+
) -> list[np.ndarray]:
|
|
73
|
+
return self._encode(queries, self.project_queries, batch_size, normalize_embeddings)
|
|
74
|
+
|
|
75
|
+
def prepare_images(
|
|
76
|
+
self, images: list[Image.Image], *, chunks: int = 8, min_pixels: int = 16384, max_pixels: int = 1800000
|
|
77
|
+
) -> dict[str, np.ndarray]:
|
|
78
|
+
return prepare_images(
|
|
79
|
+
images, self.tokenizer, self.config, chunks=chunks, min_pixels=min_pixels, max_pixels=max_pixels
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def encode_images(
|
|
83
|
+
self,
|
|
84
|
+
images: list[Image.Image],
|
|
85
|
+
batch_size: int = 1,
|
|
86
|
+
*,
|
|
87
|
+
normalize_embeddings: bool = False,
|
|
88
|
+
chunks: int = 8,
|
|
89
|
+
min_pixels: int = 16384,
|
|
90
|
+
max_pixels: int = 1800000,
|
|
91
|
+
) -> list[np.ndarray]:
|
|
92
|
+
"""Return one (band_count, embedding_dim) array per image."""
|
|
93
|
+
if batch_size < 1:
|
|
94
|
+
raise ValueError("batch_size must be positive")
|
|
95
|
+
output: list[np.ndarray] = []
|
|
96
|
+
for start in range(0, len(images), batch_size):
|
|
97
|
+
features = self.prepare_images(
|
|
98
|
+
images[start : start + batch_size], chunks=chunks, min_pixels=min_pixels, max_pixels=max_pixels
|
|
99
|
+
)
|
|
100
|
+
vectors = quantize_int8(self.model.embed_images(features))
|
|
101
|
+
if normalize_embeddings:
|
|
102
|
+
vectors = _normalize(vectors)
|
|
103
|
+
mx.eval(vectors)
|
|
104
|
+
matrix = np.array(vectors, dtype=np.float32, copy=True)
|
|
105
|
+
output.extend(row[mask] for row, mask in zip(matrix, features["chunk_mask"], strict=True))
|
|
106
|
+
return output
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_model(
|
|
110
|
+
path_or_repo: str = CHECKPOINT_0_6B,
|
|
111
|
+
revision: str | None = None,
|
|
112
|
+
dtype: str = "float32",
|
|
113
|
+
cache_limit_mb: int = 1024,
|
|
114
|
+
) -> ContextualEngine:
|
|
115
|
+
validate_dtype(dtype)
|
|
116
|
+
mx.set_cache_limit(cache_limit_mb * 1024 * 1024)
|
|
117
|
+
path = resolve_checkpoint(path_or_repo, revision)
|
|
118
|
+
model, config = load_checkpoint(path, dtype)
|
|
119
|
+
return ContextualEngine(model, ContextualTokenizer(path, config), config)
|