loraport 0.1.3__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.
- loraport/__init__.py +30 -0
- loraport/basecfg.py +92 -0
- loraport/cli.py +158 -0
- loraport/convert.py +126 -0
- loraport/delta.py +129 -0
- loraport/detect.py +40 -0
- loraport/errors.py +29 -0
- loraport/formats/__init__.py +1 -0
- loraport/formats/common.py +56 -0
- loraport/formats/mlx.py +117 -0
- loraport/formats/peft.py +125 -0
- loraport/model.py +80 -0
- loraport/render.py +90 -0
- loraport/sample.py +163 -0
- loraport/st_io.py +112 -0
- loraport-0.1.3.dist-info/METADATA +258 -0
- loraport-0.1.3.dist-info/RECORD +20 -0
- loraport-0.1.3.dist-info/WHEEL +4 -0
- loraport-0.1.3.dist-info/entry_points.txt +2 -0
- loraport-0.1.3.dist-info/licenses/LICENSE +21 -0
loraport/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""loraport: convert LoRA adapters between MLX (mlx-lm) and PEFT and verify dW."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.3"
|
|
6
|
+
|
|
7
|
+
from .convert import ConvertResult, convert # noqa: E402
|
|
8
|
+
from .delta import Report, compare # noqa: E402
|
|
9
|
+
from .detect import load_adapter # noqa: E402
|
|
10
|
+
from .errors import AdapterFormatError, BaseConfigError, LoraportError, UnsupportedAdapterError # noqa: E402
|
|
11
|
+
from .formats.mlx import write_mlx as save_mlx # noqa: E402
|
|
12
|
+
from .formats.peft import write_peft as save_peft # noqa: E402
|
|
13
|
+
from .model import Adapter, LoraModule # noqa: E402
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Adapter",
|
|
17
|
+
"AdapterFormatError",
|
|
18
|
+
"BaseConfigError",
|
|
19
|
+
"ConvertResult",
|
|
20
|
+
"LoraModule",
|
|
21
|
+
"LoraportError",
|
|
22
|
+
"Report",
|
|
23
|
+
"UnsupportedAdapterError",
|
|
24
|
+
"__version__",
|
|
25
|
+
"compare",
|
|
26
|
+
"convert",
|
|
27
|
+
"load_adapter",
|
|
28
|
+
"save_mlx",
|
|
29
|
+
"save_peft",
|
|
30
|
+
]
|
loraport/basecfg.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Cross-check adapter tensor shapes against a base model ``config.json``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .errors import BaseConfigError
|
|
10
|
+
from .model import Adapter, layer_of, terminal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class BaseDims:
|
|
15
|
+
num_layers: int
|
|
16
|
+
hidden: int
|
|
17
|
+
intermediate: int | None
|
|
18
|
+
heads: int
|
|
19
|
+
kv_heads: int
|
|
20
|
+
head_dim: int
|
|
21
|
+
|
|
22
|
+
def expected(self, term: str) -> tuple[int, int] | None:
|
|
23
|
+
"""Expected ``(in, out)`` of a well-known Llama-style projection, else None."""
|
|
24
|
+
q_out = self.heads * self.head_dim
|
|
25
|
+
kv_out = self.kv_heads * self.head_dim
|
|
26
|
+
table = {
|
|
27
|
+
"q_proj": (self.hidden, q_out),
|
|
28
|
+
"k_proj": (self.hidden, kv_out),
|
|
29
|
+
"v_proj": (self.hidden, kv_out),
|
|
30
|
+
"o_proj": (q_out, self.hidden),
|
|
31
|
+
}
|
|
32
|
+
if self.intermediate is not None:
|
|
33
|
+
table |= {
|
|
34
|
+
"gate_proj": (self.hidden, self.intermediate),
|
|
35
|
+
"up_proj": (self.hidden, self.intermediate),
|
|
36
|
+
"down_proj": (self.intermediate, self.hidden),
|
|
37
|
+
}
|
|
38
|
+
return table.get(term)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_base_config(path: str | Path) -> BaseDims:
|
|
42
|
+
"""Read ``config.json`` (a file, or a folder that contains it)."""
|
|
43
|
+
p = Path(path)
|
|
44
|
+
if p.is_dir():
|
|
45
|
+
p = p / "config.json"
|
|
46
|
+
try:
|
|
47
|
+
cfg = json.loads(p.read_text(encoding="utf-8"))
|
|
48
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
49
|
+
raise BaseConfigError(f"{p}: cannot read base model config ({e})") from e
|
|
50
|
+
if isinstance(cfg.get("text_config"), dict): # multimodal wrappers
|
|
51
|
+
cfg = cfg["text_config"]
|
|
52
|
+
try:
|
|
53
|
+
hidden = int(cfg["hidden_size"])
|
|
54
|
+
heads = int(cfg["num_attention_heads"])
|
|
55
|
+
layers = int(cfg["num_hidden_layers"])
|
|
56
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
57
|
+
raise BaseConfigError(
|
|
58
|
+
f"{p}: needs hidden_size, num_attention_heads and num_hidden_layers (missing or invalid: {e})"
|
|
59
|
+
) from e
|
|
60
|
+
inter = cfg.get("intermediate_size")
|
|
61
|
+
return BaseDims(
|
|
62
|
+
layers,
|
|
63
|
+
hidden,
|
|
64
|
+
int(inter) if inter is not None else None,
|
|
65
|
+
heads,
|
|
66
|
+
int(cfg.get("num_key_value_heads") or heads),
|
|
67
|
+
int(cfg.get("head_dim") or hidden // heads),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_adapter(adapter: Adapter, dims: BaseDims) -> list[str]:
|
|
72
|
+
"""Return human-readable problems (empty list means consistent)."""
|
|
73
|
+
problems: list[str] = []
|
|
74
|
+
for name in sorted(adapter.modules):
|
|
75
|
+
mod = adapter.modules[name]
|
|
76
|
+
layer = layer_of(name)
|
|
77
|
+
if layer is not None and not 0 <= layer < dims.num_layers:
|
|
78
|
+
problems.append(
|
|
79
|
+
f"{name}: layer {layer} is outside the base model's range 0..{dims.num_layers - 1} "
|
|
80
|
+
f"(num_hidden_layers={dims.num_layers})"
|
|
81
|
+
)
|
|
82
|
+
exp = dims.expected(terminal(name))
|
|
83
|
+
if exp is None or not mod.consistent:
|
|
84
|
+
continue
|
|
85
|
+
r = mod.rank
|
|
86
|
+
want_a, want_b = (r, exp[0]), (exp[1], r)
|
|
87
|
+
if tuple(mod.A.shape) != want_a or tuple(mod.B.shape) != want_b:
|
|
88
|
+
problems.append(
|
|
89
|
+
f"{name}: expected A{want_a} B{want_b} for the base model, "
|
|
90
|
+
f"got A{tuple(mod.A.shape)} B{tuple(mod.B.shape)}"
|
|
91
|
+
)
|
|
92
|
+
return problems
|
loraport/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Command line interface: inspect, convert, verify, demo.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 success / verification passed, 1 verification failed or tensors
|
|
4
|
+
were dropped, 2 invalid input or unsupported adapter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .convert import convert, ensure_supported
|
|
17
|
+
from .delta import compare
|
|
18
|
+
from .detect import load_adapter
|
|
19
|
+
from .errors import LoraportError, UnsupportedAdapterError
|
|
20
|
+
from .render import adapter_summary, render_inspect, render_report
|
|
21
|
+
from .sample import TRAP_NAMES, make_dora_adapter, make_mlx_adapter, make_trap, write_base_config
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cmd_inspect(args: argparse.Namespace) -> int:
|
|
25
|
+
adapter = load_adapter(args.path)
|
|
26
|
+
if args.json:
|
|
27
|
+
print(json.dumps(adapter_summary(adapter, str(args.path)), indent=2))
|
|
28
|
+
else:
|
|
29
|
+
print(render_inspect(adapter, str(args.path)))
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cmd_convert(args: argparse.Namespace) -> int:
|
|
34
|
+
res = convert(
|
|
35
|
+
args.src,
|
|
36
|
+
args.dst,
|
|
37
|
+
args.to,
|
|
38
|
+
base_config=args.base_config,
|
|
39
|
+
base_model_id=args.base_model_id,
|
|
40
|
+
verify=not args.no_verify,
|
|
41
|
+
rtol=args.rtol,
|
|
42
|
+
force=args.force,
|
|
43
|
+
)
|
|
44
|
+
print(f"wrote {args.to} adapter to {res.dst}")
|
|
45
|
+
for note in res.notes:
|
|
46
|
+
print(f"note: {note}")
|
|
47
|
+
if res.report is not None:
|
|
48
|
+
print(render_report(res.report, "source", "converted"))
|
|
49
|
+
else:
|
|
50
|
+
print("verification skipped (--no-verify)")
|
|
51
|
+
if res.dropped:
|
|
52
|
+
print(f" source tensors that were NOT carried over ({len(res.dropped)}):")
|
|
53
|
+
for line in res.dropped[:12]:
|
|
54
|
+
print(f" - {line}")
|
|
55
|
+
if res.ok:
|
|
56
|
+
print("next steps:")
|
|
57
|
+
for line in res.next_steps:
|
|
58
|
+
print(f" {line}")
|
|
59
|
+
return 0
|
|
60
|
+
print("conversion is NOT safe to use: see the problems above", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _cmd_verify(args: argparse.Namespace) -> int:
|
|
65
|
+
a, b = load_adapter(args.a), load_adapter(args.b)
|
|
66
|
+
ensure_supported(a)
|
|
67
|
+
ensure_supported(b)
|
|
68
|
+
report = compare(a, b, rtol=args.rtol)
|
|
69
|
+
if args.json:
|
|
70
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
71
|
+
else:
|
|
72
|
+
print(render_report(report, str(args.a), str(args.b)))
|
|
73
|
+
return 0 if report.ok else 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _headline(res_report) -> str: # type: ignore[no-untyped-def]
|
|
77
|
+
return res_report.headline() if res_report is not None else "not verified"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cmd_demo(args: argparse.Namespace) -> int:
|
|
81
|
+
out = Path(args.out)
|
|
82
|
+
print("loraport demo: synthetic practice adapters (mock), random numbers, not a trained model")
|
|
83
|
+
mlx_dir = make_mlx_adapter(out / "mlx")
|
|
84
|
+
base_cfg = write_base_config(out / "base")
|
|
85
|
+
print(f"1. made a practice MLX adapter in {mlx_dir} (rank 4, scale 20, last 2 of 4 blocks)")
|
|
86
|
+
|
|
87
|
+
# real conversion + real verification, both directions
|
|
88
|
+
to_peft = convert(mlx_dir, out / "peft", "peft", force=True, base_config=base_cfg)
|
|
89
|
+
print(f"2. MLX -> PEFT: {_headline(to_peft.report)}")
|
|
90
|
+
back = convert(out / "peft", out / "mlx_roundtrip", "mlx", force=True, base_config=base_cfg)
|
|
91
|
+
print(f"3. PEFT -> MLX: {_headline(back.report)}")
|
|
92
|
+
|
|
93
|
+
print("4. common mistakes, each compared with the original MLX adapter:")
|
|
94
|
+
reference = load_adapter(mlx_dir)
|
|
95
|
+
caught = 0
|
|
96
|
+
for kind in TRAP_NAMES:
|
|
97
|
+
rep = compare(reference, load_adapter(make_trap(kind, mlx_dir, out / "traps" / kind)))
|
|
98
|
+
caught += 0 if rep.ok else 1
|
|
99
|
+
print(f" {kind:<18} {'caught' if not rep.ok else 'MISSED'}: {rep.headline()}")
|
|
100
|
+
print(f"{caught}/{len(TRAP_NAMES)} traps caught")
|
|
101
|
+
# extra folder for README section 5 (a DoRA adapter that convert must refuse); prints nothing
|
|
102
|
+
make_dora_adapter(mlx_dir, out / "dora")
|
|
103
|
+
return 0 if to_peft.ok and back.ok and caught == len(TRAP_NAMES) else 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
107
|
+
p = argparse.ArgumentParser(
|
|
108
|
+
prog="loraport",
|
|
109
|
+
description="Convert LoRA adapters between MLX (mlx-lm) and PEFT and verify the weight delta.",
|
|
110
|
+
)
|
|
111
|
+
p.add_argument("--version", action="version", version=f"loraport {__version__}")
|
|
112
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
113
|
+
|
|
114
|
+
sp = sub.add_parser("inspect", help="show format, rank, scale and layers of an adapter folder")
|
|
115
|
+
sp.add_argument("path", type=Path)
|
|
116
|
+
sp.add_argument("--json", action="store_true", help="machine-readable output")
|
|
117
|
+
sp.set_defaults(func=_cmd_inspect)
|
|
118
|
+
|
|
119
|
+
sp = sub.add_parser("convert", help="convert an adapter to the other format and verify it")
|
|
120
|
+
sp.add_argument("src", type=Path)
|
|
121
|
+
sp.add_argument("dst", type=Path)
|
|
122
|
+
sp.add_argument("--to", choices=("peft", "mlx"), required=True)
|
|
123
|
+
sp.add_argument("--base-config", type=Path, help="base model config.json (or its folder) for shape/layer checks")
|
|
124
|
+
sp.add_argument("--base-model-id", help="hub id written into the output config (e.g. Qwen/Qwen2.5-1.5B-Instruct)")
|
|
125
|
+
sp.add_argument("--rtol", type=float, default=1e-5, help="max relative dW error (default 1e-5)")
|
|
126
|
+
sp.add_argument("--no-verify", action="store_true", help="skip the automatic verification")
|
|
127
|
+
sp.add_argument("--force", action="store_true", help="overwrite adapter files in dst")
|
|
128
|
+
sp.set_defaults(func=_cmd_convert)
|
|
129
|
+
|
|
130
|
+
sp = sub.add_parser("verify", help="compare the dW of two adapters (any formats)")
|
|
131
|
+
sp.add_argument("a", type=Path, help="reference adapter")
|
|
132
|
+
sp.add_argument("b", type=Path, help="adapter to check")
|
|
133
|
+
sp.add_argument("--rtol", type=float, default=1e-5)
|
|
134
|
+
sp.add_argument("--json", action="store_true")
|
|
135
|
+
sp.set_defaults(func=_cmd_verify)
|
|
136
|
+
|
|
137
|
+
sp = sub.add_parser("demo", help="run the whole flow on synthetic practice adapters (mock)")
|
|
138
|
+
sp.add_argument("--out", type=Path, default=Path("loraport-demo"))
|
|
139
|
+
sp.set_defaults(func=_cmd_demo)
|
|
140
|
+
return p
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
144
|
+
args = build_parser().parse_args(argv)
|
|
145
|
+
try:
|
|
146
|
+
return int(args.func(args))
|
|
147
|
+
except UnsupportedAdapterError as e:
|
|
148
|
+
print("error: refusing to convert an unsupported adapter:", file=sys.stderr)
|
|
149
|
+
for reason in e.reasons:
|
|
150
|
+
print(f" - {reason}", file=sys.stderr)
|
|
151
|
+
return 2
|
|
152
|
+
except LoraportError as e: # includes BaseConfigError, AdapterFormatError
|
|
153
|
+
print(f"error: {e}", file=sys.stderr)
|
|
154
|
+
return 2
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
sys.exit(main())
|
loraport/convert.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Convert between MLX and PEFT adapter folders, then verify the result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from .basecfg import check_adapter, load_base_config
|
|
10
|
+
from .delta import Report, compare
|
|
11
|
+
from .detect import load_adapter
|
|
12
|
+
from .errors import AdapterFormatError, BaseConfigError, LoraportError, UnsupportedAdapterError
|
|
13
|
+
from .formats.common import CONFIG_NAME
|
|
14
|
+
from .formats.mlx import WEIGHTS_NAME as MLX_WEIGHTS
|
|
15
|
+
from .formats.mlx import write_mlx
|
|
16
|
+
from .formats.peft import WEIGHTS_NAME as PEFT_WEIGHTS
|
|
17
|
+
from .formats.peft import write_peft
|
|
18
|
+
from .model import Adapter
|
|
19
|
+
|
|
20
|
+
Target = Literal["peft", "mlx"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ConvertResult:
|
|
25
|
+
dst: Path
|
|
26
|
+
adapter: Adapter # the source adapter as read
|
|
27
|
+
report: Report | None # None when verification was skipped
|
|
28
|
+
next_steps: list[str] = field(default_factory=list)
|
|
29
|
+
notes: list[str] = field(default_factory=list)
|
|
30
|
+
dropped: list[str] = field(default_factory=list) # source tensors that did not make it into the output
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def ok(self) -> bool:
|
|
34
|
+
"""True only if nothing was dropped and (when verified) the report passed."""
|
|
35
|
+
return not self.dropped and (self.report is None or self.report.ok)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ensure_supported(adapter: Adapter) -> None:
|
|
39
|
+
"""Raise ``UnsupportedAdapterError`` if the adapter uses a feature we refuse to convert."""
|
|
40
|
+
if adapter.unsupported:
|
|
41
|
+
raise UnsupportedAdapterError(adapter.unsupported)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _validate(adapter: Adapter) -> None:
|
|
45
|
+
ensure_supported(adapter)
|
|
46
|
+
if not adapter.modules:
|
|
47
|
+
raise AdapterFormatError("no LoRA modules found in the adapter; nothing to convert")
|
|
48
|
+
bad = []
|
|
49
|
+
for name, mod in sorted(adapter.modules.items()):
|
|
50
|
+
if not mod.consistent:
|
|
51
|
+
bad.append(f"{name}: A{tuple(mod.A.shape)} and B{tuple(mod.B.shape)} do not share the rank dimension")
|
|
52
|
+
elif mod.rank != adapter.rank:
|
|
53
|
+
bad.append(f"{name}: tensor rank {mod.rank} differs from the configured rank {adapter.rank}")
|
|
54
|
+
if bad:
|
|
55
|
+
raise AdapterFormatError(
|
|
56
|
+
"inconsistent adapter, refusing to convert (was lora_A/lora_B transposed or the config edited?): "
|
|
57
|
+
+ "; ".join(bad[:5])
|
|
58
|
+
+ (f" (+{len(bad) - 5} more)" if len(bad) > 5 else "")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _next_steps(to: Target, dst: Path, base: str | None, num_layers_unknown: bool) -> list[str]:
|
|
63
|
+
base_ref = base or "<BASE_MODEL>"
|
|
64
|
+
if to == "peft":
|
|
65
|
+
return [
|
|
66
|
+
f"vLLM: vllm serve {base_ref} --enable-lora --lora-modules my-adapter={dst}",
|
|
67
|
+
f"llama.cpp: python convert_lora_to_gguf.py --base <BASE_MODEL_DIR> --outfile adapter.gguf {dst}",
|
|
68
|
+
]
|
|
69
|
+
steps = [f'mlx-lm: mlx_lm.generate --model {base_ref} --adapter-path {dst} --prompt "Hello"']
|
|
70
|
+
if num_layers_unknown:
|
|
71
|
+
steps.append("num_layers=-1 was written (no --base-config); blocks without tensors keep dW=0.")
|
|
72
|
+
return steps
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def convert(
|
|
76
|
+
src: str | Path,
|
|
77
|
+
dst: str | Path,
|
|
78
|
+
to: Target,
|
|
79
|
+
*,
|
|
80
|
+
base_config: str | Path | None = None,
|
|
81
|
+
base_model_id: str | None = None,
|
|
82
|
+
verify: bool = True,
|
|
83
|
+
rtol: float = 1e-5,
|
|
84
|
+
force: bool = False,
|
|
85
|
+
) -> ConvertResult:
|
|
86
|
+
"""Convert the adapter in ``src`` to ``to`` ("peft" or "mlx") inside ``dst``.
|
|
87
|
+
|
|
88
|
+
Raises ``UnsupportedAdapterError`` / ``AdapterFormatError`` / ``BaseConfigError``
|
|
89
|
+
before writing anything. Tensors the converter could not carry over are
|
|
90
|
+
never silent: they show up in ``ConvertResult.dropped`` and in the report.
|
|
91
|
+
"""
|
|
92
|
+
if to not in ("peft", "mlx"):
|
|
93
|
+
raise LoraportError(f"unknown target format {to!r}; use 'peft' or 'mlx'")
|
|
94
|
+
adapter = load_adapter(src)
|
|
95
|
+
_validate(adapter)
|
|
96
|
+
if adapter.format == to:
|
|
97
|
+
raise AdapterFormatError(
|
|
98
|
+
f"{src} is already a {to} adapter; convert to the other format "
|
|
99
|
+
f"(--to {'mlx' if to == 'peft' else 'peft'}) or use `loraport verify`"
|
|
100
|
+
)
|
|
101
|
+
total_layers = None
|
|
102
|
+
if base_config is not None:
|
|
103
|
+
dims = load_base_config(base_config)
|
|
104
|
+
problems = check_adapter(adapter, dims)
|
|
105
|
+
if problems:
|
|
106
|
+
raise BaseConfigError(
|
|
107
|
+
"adapter does not fit the base model config:\n " + "\n ".join(problems), problems
|
|
108
|
+
)
|
|
109
|
+
total_layers = dims.num_layers
|
|
110
|
+
|
|
111
|
+
out = Path(dst)
|
|
112
|
+
if not force and any((out / n).exists() for n in (PEFT_WEIGHTS, MLX_WEIGHTS, CONFIG_NAME)):
|
|
113
|
+
raise LoraportError(f"{out} already contains adapter files; pass --force to overwrite them")
|
|
114
|
+
|
|
115
|
+
if to == "peft":
|
|
116
|
+
write_peft(adapter, out, base_model_id=base_model_id)
|
|
117
|
+
else:
|
|
118
|
+
write_mlx(adapter, out, total_layers=total_layers, base_model_id=base_model_id)
|
|
119
|
+
|
|
120
|
+
result_adapter = load_adapter(out)
|
|
121
|
+
dropped = list(adapter.key_issues)
|
|
122
|
+
report = compare(adapter, result_adapter, rtol=rtol) if verify else None
|
|
123
|
+
notes = list(adapter.warnings)
|
|
124
|
+
return ConvertResult(
|
|
125
|
+
out, adapter, report, _next_steps(to, out, base_model_id, to == "mlx" and total_layers is None), notes, dropped
|
|
126
|
+
)
|
loraport/delta.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Compare adapters by their weight delta dW = scale * B @ A without building dW.
|
|
2
|
+
|
|
3
|
+
For ``U = [s1*B1, -s2*B2]`` (out x (r1+r2)) and ``V = [A1; A2]`` we have
|
|
4
|
+
``dW1 - dW2 = U @ V`` and ``||U V||_F^2 = trace((U^T U)(V V^T))``, which only
|
|
5
|
+
needs (r1+r2)-square matrices. Everything runs in float64. Squaring limits the
|
|
6
|
+
precision floor of a relative error to about 1e-8, far below any real mistake.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .model import Adapter, LoraModule
|
|
18
|
+
|
|
19
|
+
_TINY = 1e-30
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sq_norm(U: np.ndarray, V: np.ndarray) -> float:
|
|
23
|
+
gram_u = U.T @ U
|
|
24
|
+
gram_v = V @ V.T
|
|
25
|
+
return max(float(np.sum(gram_u * gram_v)), 0.0) # both Gram matrices are symmetric
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def delta_norm(m: LoraModule) -> float:
|
|
29
|
+
"""Frobenius norm of dW."""
|
|
30
|
+
A = m.A.astype(np.float64)
|
|
31
|
+
B = m.B.astype(np.float64)
|
|
32
|
+
return math.sqrt(_sq_norm(m.scale * B, A))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def diff_norm(m1: LoraModule, m2: LoraModule) -> float:
|
|
36
|
+
"""Frobenius norm of dW1 - dW2 (shapes must match)."""
|
|
37
|
+
U = np.concatenate([m1.scale * m1.B.astype(np.float64), -m2.scale * m2.B.astype(np.float64)], axis=1)
|
|
38
|
+
V = np.concatenate([m1.A.astype(np.float64), m2.A.astype(np.float64)], axis=0)
|
|
39
|
+
return math.sqrt(_sq_norm(U, V))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def dense_delta(m: LoraModule) -> np.ndarray:
|
|
43
|
+
"""Materialise dW (float64). Only for tests and small modules."""
|
|
44
|
+
return m.scale * (m.B.astype(np.float64) @ m.A.astype(np.float64))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _shapes(m: LoraModule) -> str:
|
|
48
|
+
return f"A{tuple(m.A.shape)} B{tuple(m.B.shape)}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Report:
|
|
53
|
+
"""Result of comparing a reference adapter ``a`` with a candidate ``b``."""
|
|
54
|
+
|
|
55
|
+
ok: bool
|
|
56
|
+
rtol: float
|
|
57
|
+
n_reference: int
|
|
58
|
+
missing: list[str] = field(default_factory=list) # in a, absent in b
|
|
59
|
+
extra: list[str] = field(default_factory=list) # in b, absent in a
|
|
60
|
+
shape_mismatch: list[str] = field(default_factory=list)
|
|
61
|
+
key_issues: list[str] = field(default_factory=list)
|
|
62
|
+
per_module: dict[str, float] = field(default_factory=dict) # relative dW error
|
|
63
|
+
worst_rel_err: float = 0.0
|
|
64
|
+
worst_module: str | None = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def bad_modules(self) -> list[str]:
|
|
68
|
+
return [n for n, e in self.per_module.items() if not e <= self.rtol]
|
|
69
|
+
|
|
70
|
+
def headline(self) -> str:
|
|
71
|
+
good = self.n_reference - len(self.missing) - len(self.shape_mismatch) - len(self.bad_modules)
|
|
72
|
+
if self.ok:
|
|
73
|
+
return f"PASS {self.n_reference}/{self.n_reference} modules, worst rel err {self.worst_rel_err:.1e}"
|
|
74
|
+
return (
|
|
75
|
+
f"FAIL {max(good, 0)}/{self.n_reference} modules match "
|
|
76
|
+
f"(missing {len(self.missing)}, extra {len(self.extra)}, shape mismatch {len(self.shape_mismatch)}, "
|
|
77
|
+
f"dW off {len(self.bad_modules)}, ignored/dropped tensors {len(self.key_issues)})"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict[str, Any]:
|
|
81
|
+
def num(x: float) -> float | None:
|
|
82
|
+
return x if math.isfinite(x) else None
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"ok": self.ok,
|
|
86
|
+
"rtol": self.rtol,
|
|
87
|
+
"modules_in_reference": self.n_reference,
|
|
88
|
+
"missing": self.missing,
|
|
89
|
+
"extra": self.extra,
|
|
90
|
+
"shape_mismatch": self.shape_mismatch,
|
|
91
|
+
"key_issues": self.key_issues,
|
|
92
|
+
"bad_modules": self.bad_modules,
|
|
93
|
+
"worst_rel_err": num(self.worst_rel_err),
|
|
94
|
+
"worst_module": self.worst_module,
|
|
95
|
+
"per_module": {k: num(v) for k, v in self.per_module.items()},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def compare(a: Adapter, b: Adapter, *, rtol: float = 1e-5) -> Report:
|
|
100
|
+
"""Compare per-module dW of ``a`` (reference) and ``b``.
|
|
101
|
+
|
|
102
|
+
The report is ``ok`` only if no module is missing/extra, no shape differs,
|
|
103
|
+
no tensor was ignored or dropped on either side, and every relative error
|
|
104
|
+
``||dW_a - dW_b|| / ||dW_a||`` is within ``rtol``.
|
|
105
|
+
"""
|
|
106
|
+
missing = sorted(a.modules.keys() - b.modules.keys())
|
|
107
|
+
extra = sorted(b.modules.keys() - a.modules.keys())
|
|
108
|
+
shape_mismatch: list[str] = []
|
|
109
|
+
per_module: dict[str, float] = {}
|
|
110
|
+
for name in sorted(a.modules.keys() & b.modules.keys()):
|
|
111
|
+
ma, mb = a.modules[name], b.modules[name]
|
|
112
|
+
if not (ma.consistent and mb.consistent) or ma.A.shape != mb.A.shape or ma.B.shape != mb.B.shape:
|
|
113
|
+
shape_mismatch.append(f"{name}: {_shapes(ma)} vs {_shapes(mb)}")
|
|
114
|
+
continue
|
|
115
|
+
ref = delta_norm(ma)
|
|
116
|
+
diff = diff_norm(ma, mb)
|
|
117
|
+
if ref < _TINY:
|
|
118
|
+
per_module[name] = 0.0 if diff < _TINY else math.inf
|
|
119
|
+
else:
|
|
120
|
+
per_module[name] = diff / ref
|
|
121
|
+
issues = [f"a: {s}" for s in a.key_issues] + [f"b: {s}" for s in b.key_issues]
|
|
122
|
+
worst_module = max(per_module, key=lambda n: per_module[n], default=None)
|
|
123
|
+
worst = per_module[worst_module] if worst_module else 0.0
|
|
124
|
+
ok = (
|
|
125
|
+
len(a.modules) > 0
|
|
126
|
+
and not (missing or extra or shape_mismatch or issues)
|
|
127
|
+
and all(e <= rtol for e in per_module.values())
|
|
128
|
+
)
|
|
129
|
+
return Report(ok, rtol, len(a.modules), missing, extra, shape_mismatch, issues, per_module, worst, worst_module)
|
loraport/detect.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Decide whether a folder holds an MLX or a PEFT adapter and load it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import AdapterFormatError
|
|
8
|
+
from .formats.mlx import WEIGHTS_NAME as MLX_WEIGHTS
|
|
9
|
+
from .formats.mlx import read_mlx
|
|
10
|
+
from .formats.peft import WEIGHTS_NAME as PEFT_WEIGHTS
|
|
11
|
+
from .formats.peft import read_peft
|
|
12
|
+
from .model import Adapter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect_format(path: Path) -> str:
|
|
16
|
+
"""Return ``"mlx"`` or ``"peft"`` based on the weight file name in ``path``."""
|
|
17
|
+
if not path.exists():
|
|
18
|
+
raise AdapterFormatError(f"{path}: no such directory (check the path)")
|
|
19
|
+
if not path.is_dir():
|
|
20
|
+
raise AdapterFormatError(f"{path}: not a directory (pass the adapter folder, not a file)")
|
|
21
|
+
has_mlx = (path / MLX_WEIGHTS).is_file()
|
|
22
|
+
has_peft = (path / PEFT_WEIGHTS).is_file()
|
|
23
|
+
if has_mlx and has_peft:
|
|
24
|
+
raise AdapterFormatError(
|
|
25
|
+
f"{path}: contains both {MLX_WEIGHTS} and {PEFT_WEIGHTS}; keep one adapter per folder"
|
|
26
|
+
)
|
|
27
|
+
if has_mlx:
|
|
28
|
+
return "mlx"
|
|
29
|
+
if has_peft:
|
|
30
|
+
return "peft"
|
|
31
|
+
raise AdapterFormatError(
|
|
32
|
+
f"{path}: no adapter found; expected {MLX_WEIGHTS} (mlx-lm) or {PEFT_WEIGHTS} (PEFT) "
|
|
33
|
+
"together with adapter_config.json"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_adapter(path: str | Path) -> Adapter:
|
|
38
|
+
"""Load an adapter folder in either format."""
|
|
39
|
+
p = Path(path)
|
|
40
|
+
return read_mlx(p) if detect_format(p) == "mlx" else read_peft(p)
|
loraport/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Exceptions raised by loraport. The CLI maps every ``LoraportError`` to exit code 2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LoraportError(Exception):
|
|
9
|
+
"""Base class for all expected, user-fixable errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AdapterFormatError(LoraportError):
|
|
13
|
+
"""The folder is not a readable MLX or PEFT LoRA adapter (or cannot be written)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsupportedAdapterError(LoraportError):
|
|
17
|
+
"""The adapter uses a feature loraport refuses to convert instead of silently breaking it."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, reasons: Iterable[str]) -> None:
|
|
20
|
+
self.reasons: list[str] = list(reasons)
|
|
21
|
+
super().__init__("unsupported adapter: " + "; ".join(self.reasons))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BaseConfigError(LoraportError):
|
|
25
|
+
"""The base model ``config.json`` is unreadable or disagrees with the adapter tensors."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, problems: Iterable[str] = ()) -> None:
|
|
28
|
+
self.problems: list[str] = list(problems)
|
|
29
|
+
super().__init__(message)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Format readers and writers (``mlx``, ``peft``)."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Helpers shared by the MLX and PEFT readers/writers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ..errors import AdapterFormatError
|
|
12
|
+
from ..model import LoraModule
|
|
13
|
+
from ..st_io import SafetensorsFile
|
|
14
|
+
|
|
15
|
+
CONFIG_NAME = "adapter_config.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
19
|
+
try:
|
|
20
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
21
|
+
except FileNotFoundError as e:
|
|
22
|
+
raise AdapterFormatError(f"{path}: file not found") from e
|
|
23
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
24
|
+
raise AdapterFormatError(f"{path}: cannot parse JSON ({e})") from e
|
|
25
|
+
if not isinstance(data, dict):
|
|
26
|
+
raise AdapterFormatError(f"{path}: expected a JSON object")
|
|
27
|
+
return data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pair_modules(
|
|
31
|
+
st: SafetensorsFile,
|
|
32
|
+
a_keys: dict[str, str],
|
|
33
|
+
b_keys: dict[str, str],
|
|
34
|
+
scale: float,
|
|
35
|
+
transpose: bool,
|
|
36
|
+
issues: list[str],
|
|
37
|
+
a_leaf: str,
|
|
38
|
+
b_leaf: str,
|
|
39
|
+
) -> dict[str, LoraModule]:
|
|
40
|
+
"""Read A/B tensors that have a partner; report the ones that do not.
|
|
41
|
+
|
|
42
|
+
``a_keys``/``b_keys`` map module name -> tensor key. When ``transpose`` is
|
|
43
|
+
true the tensors are in MLX layout and are transposed to ``(r,in)``/``(out,r)``.
|
|
44
|
+
"""
|
|
45
|
+
modules: dict[str, LoraModule] = {}
|
|
46
|
+
for name in sorted(a_keys.keys() - b_keys.keys()):
|
|
47
|
+
issues.append(f"{a_keys[name]}: no matching {b_leaf} tensor, module {name!r} is incomplete and was skipped")
|
|
48
|
+
for name in sorted(b_keys.keys() - a_keys.keys()):
|
|
49
|
+
issues.append(f"{b_keys[name]}: no matching {a_leaf} tensor, module {name!r} is incomplete and was skipped")
|
|
50
|
+
for name in sorted(a_keys.keys() & b_keys.keys()):
|
|
51
|
+
a = st.read(a_keys[name])
|
|
52
|
+
b = st.read(b_keys[name])
|
|
53
|
+
if transpose:
|
|
54
|
+
a, b = a.T, b.T
|
|
55
|
+
modules[name] = LoraModule(np.ascontiguousarray(a), np.ascontiguousarray(b), scale)
|
|
56
|
+
return modules
|