Pixedar's picture
Deploy TraceScope API
ce11d27
Raw
History Blame Contribute Delete
41.4 kB
# #!/usr/bin/env python3
# """
# qualia_segmenter_highdim.py — adaptive‑CROPS (no duplicates!)
# -------------------------------------------------------------
#
# Detect the start of the *current* qualia episode in raw 3072‑D embeddings.
#
# Fixes compared with the previous build
# ======================================
# * **Adaptive penalty path**: starts at 0.5 and multiplies by (1+step)
# until > pen_max (default 20). The step is 2 % by default, but the code
# skips any penalty that yields the *same* breakpoint list as the
# previous — so you get **one line per distinct segmentation**.
# * **Elbow selection**: pick the index with the *largest* cohesion jump
# (relative drop ≥ tol, default 5 %). Falls back to the finest level
# with > 1 segment if no clear elbow.
# """
# from __future__ import annotations
#
# import json
# from typing import List, Tuple
#
# import numpy as np
#
# try:
# import ruptures as rpt
# except ModuleNotFoundError as e:
# raise ModuleNotFoundError(
# "qualia_segmenter_highdim requires the 'ruptures' package.\n"
# "Install it with: pip install ruptures"
# ) from e
#
#
#
#
# from typing import List, Optional
#
#
#
# from sklearn.metrics import calinski_harabasz_score
#
#
# # --------------------------------------------------------------------------- #
# # Cosine‑velocity #
# # --------------------------------------------------------------------------- #
# def _cosine_velocity(x: np.ndarray) -> np.ndarray:
# """Angular ‘speed’ between consecutive embeddings (verbose)."""
# print(f"[cos-vel] embeddings.shape = {x.shape}")
# eps = 1e-12
# norms = np.linalg.norm(x, axis=1, keepdims=True) + eps
# u = x / norms
# cos_sim = np.sum(u[:-1] * u[1:], axis=1)
# v = 1.0 - np.clip(cos_sim, -1.0, 1.0)
# print(f"[cos-vel] velocity length={len(v)}, first 5: {v[:5]}")
# return v
#
#
# # --------------------------------------------------------------------------- #
# # Adaptive CROPS: *only* distinct breakpoint sets #
# # --------------------------------------------------------------------------- #
# def crops_distinct(
# velocity: np.ndarray,
# *,
# pen_min: float = 0.5,
# pen_max: float = 20.0,
# rel_step: float = 0.02,
# min_size: int = 5,
# model: str = "rbf",
# max_levels: int = 200,
# ) -> List[Tuple[float, List[int]]]:
# """
# March through penalty space multiplicatively:
# pen *= (1 + rel_step)
# but **emit a row only when the breakpoint list changes**.
# """
# algo = rpt.Pelt(model=model, min_size=min_size).fit(velocity.reshape(-1, 1))
#
# path: List[Tuple[float, List[int]]] = []
# p = pen_min
# last_bkps: List[int] | None = None
# levels = 0
#
# while p <= pen_max and levels < max_levels:
# bkps = algo.predict(pen=p)
# if last_bkps is None or bkps != last_bkps:
# path.append((p, bkps))
# last_bkps = bkps
# levels += 1
# p *= 1.0 + rel_step
#
# # stop early once only one segment is left
# if last_bkps == [len(velocity)]:
# break
#
# # ─── Debug dump ──────────────────────────────────────────────────────
# print("\n[CROPS‑distinct] Hierarchy (unique levels):")
# for p, b in path:
# print(f" pen={p:8.3f} → {b}")
# print("[CROPS‑distinct] ────────────────\n")
# # ────────────────────────────────────────────────────────────────────
# return path
#
#
# # --------------------------------------------------------------------------- #
# # Cohesion score #
# # --------------------------------------------------------------------------- #
# def _cohesion(emb: np.ndarray, bkps: List[int]) -> float:
# """Davies–Bouldin‑style cohesion (∞ if only one segment)."""
# if len(bkps) <= 1: # one segment
# return float("inf")
#
# start, W, M = 0, [], []
# for b in bkps:
# seg = emb[start:b]
# W.append(seg.var(axis=0).mean())
# M.append(seg.mean(axis=0))
# start = b
#
# intra = float(np.mean(W))
# inter = float(
# np.mean([np.linalg.norm(a - b)
# for i, a in enumerate(M) for j, b in enumerate(M) if j > i])
# )
# return intra / (inter + 1e-9)
#
#
# # --------------------------------------------------------------------------- #
# # Choose elbow by largest cohesion drop #
# # --------------------------------------------------------------------------- #
# def pick_elbow(
# levels: List[Tuple[float, List[int]]],
# emb: np.ndarray,
# tol: float = 0.05,
# ) -> Tuple[float, List[int]]:
# """
# Return the (penalty, bkps) pair with the biggest *relative* cohesion drop
# (score[i] / score[i+1] ≥ 1 + tol). If none qualify, pick the finest
# level that has more than one segment, otherwise the coarsest.
# """
# scores = [(_cohesion(emb, b)) for _, b in levels]
#
# print("[cohesion] level → score:")
# for (p, _), s in zip(levels, scores):
# print(f" {p:8.3f} → {s if np.isfinite(s) else '∞'}")
#
# best_idx = None
# best_ratio = 1.0
# for i in range(len(scores) - 1):
# if not (np.isfinite(scores[i]) and np.isfinite(scores[i + 1])):
# continue
# ratio = scores[i] / scores[i + 1]
# if ratio >= 1.0 + tol and ratio > best_ratio:
# best_ratio = ratio
# best_idx = i + 1 # keep the *lower* score side (i+1)
#
# if best_idx is None:
# # fall‑back: finest >1‑segment level, else last
# for i in reversed(range(len(levels))):
# if len(levels[i][1]) > 1:
# best_idx = i
# break
# if best_idx is None:
# best_idx = len(levels) - 1
#
# print(f"[cohesion] picked elbow @ pen={levels[best_idx][0]:.3f}, "
# f"ratio={best_ratio:.3f}\n")
# return levels[best_idx]
#
# # ─── Vendored sliding_window_view ────────────────────────────────────────
# try:
# # Use NumPy’s built-in if available
# from numpy.lib.stride_tricks import sliding_window_view
# except ImportError:
# # Otherwise, fall back to as_strided for 2-D arrays + full-width windows
# from numpy.lib.stride_tricks import as_strided
#
# def sliding_window_view(x: np.ndarray, window_shape):
# """
# Vendored replacement for numpy>=1.20:
# x: array of shape (T, D)
# window_shape: tuple (w, D)
# returns view of shape (T-w+1, w, D)
# """
# w, D = window_shape
# T, D0 = x.shape
# if D0 != D:
# raise ValueError(f"sliding_window_view: expected second dim={D}, got {D0}")
# # If window longer than axis, return empty as numpy would
# if T < w:
# return np.empty((0, w, D), dtype=x.dtype)
# shape = (T - w + 1, w, D)
# strides = (x.strides[0], x.strides[0], x.strides[1])
# return as_strided(x, shape=shape, strides=strides)
# # --------------------------------------------------------------------------- #
# # Recent‑segment extractor #
# # --------------------------------------------------------------------------- #
# def _recent_slice(
# embeddings: np.ndarray,
# *,
# pen_min: float = 0.5,
# pen_max: float = 20.0,
# rel_step: float = 0.02,
# min_size: int = 5,
# model: str = "rbf",
# tol: float = 0.05,
# ) -> Tuple[np.ndarray, int, List[int]]:
# """
# Returns (recent_slice, start_idx, chosen_breakpoints).
# """
# if embeddings.shape[0] < 2:
# return np.empty((0, embeddings.shape[1])), 0, [0]
#
# # ─── SMOOTH A 7-DAY WINDOW ─────────────────────────────────────────
# ###
# win = 57
# pad = win // 2
# emb_pad = np.pad(embeddings, [(pad, pad), (0, 0)], mode="edge")
# # exactly the same call signature as before:
# embeddings_smooth = sliding_window_view(
# emb_pad,
# (win, embeddings.shape[1])
# ).mean(axis=2)
#
# velocity = _cosine_velocity(embeddings_smooth)
# levels = crops_distinct(
# velocity,
# pen_min=pen_min,
# pen_max=pen_max,
# rel_step=rel_step,
# min_size=min_size,
# model=model,
# )
# pen_sel, bkps_sel = pick_elbow(levels, embeddings, tol=tol)
#
# # --- Pretty print ------------------------------------------------------
# print("[hierarchy] Nested (unique) tree; picked level marked:")
# for p, b in levels:
# prefix = "└─" if p == pen_sel else "├─"
# seg_lens = list(np.diff([0] + b))
# mark = " ← picked" if p == pen_sel else ""
# print(f"{prefix} pen={p:8.3f} → {b}\n seg‑lens: {seg_lens}{mark}")
# print("[hierarchy] ───────────────────────────\n")
# # ----------------------------------------------------------------------
#
# start_idx = bkps_sel[-2] + 1 if len(bkps_sel) >= 2 else 0
# return embeddings[start_idx:].copy(), start_idx, bkps_sel
#
#
# # --------------------------------------------------------------------------- #
# # Public API #
# # --------------------------------------------------------------------------- #
# def recent_qualia_from_json(
# embeddings_json: str,
# *,
# pen_min: float = 0.5,
# pen_max: float = 20.0,
# rel_step: float = 0.02,
# min_size: int = 5,
# model: str = "rbf",
# tol: float = 0.05,
# ) -> str:
# """
# Parse a JSON list‑of‑lists, extract the *current* qualia episode,
# and return compact JSON with keys:
# recent_embeddings, start_idx, breakpoints
# """
# print("[API] Parsing embeddings JSON …")
# try:
# arr = np.asarray(json.loads(embeddings_json), dtype=float)
# except Exception as exc:
# raise ValueError(
# "Failed to parse embeddings_json — must be a JSON list of lists."
# ) from exc
# if arr.ndim != 2:
# raise ValueError("Input embeddings must be 2‑D.")
#
# recent, start_idx, bkps = _recent_slice(
# arr,
# pen_min=pen_min,
# pen_max=pen_max,
# rel_step=rel_step,
# min_size=min_size,
# model=model,
# tol=tol,
# )
# print(f"[API] start_idx={start_idx}, bkps={bkps}, recent_len={len(recent)}")
#
# result = {
# "recent_embeddings": recent.tolist(),
# "start_idx": int(start_idx),
# "breakpoints": [int(i) for i in bkps],
# }
# print("[API] Done.\n")
# return json.dumps(result, separators=(",", ":"))
#
#
#
# def compute_distances_from_centroids(
# embeddings: np.ndarray, labels: np.ndarray
# ) -> Tuple[np.ndarray, np.ndarray]:
# """
# Given embeddings of shape (N, D) and integer cluster labels of length N,
# compute for each point the Euclidean distance to each cluster centroid.
# Returns (distances of shape (N, C), unique_labels).
# """
# unique_labels = np.unique(labels)
# centroids = np.vstack([
# embeddings[labels == c].mean(axis=0)
# for c in unique_labels
# ]) # shape (C, D)
# dists = np.linalg.norm(
# embeddings[:, None, :] - centroids[None, :, :], axis=2
# )
# return dists, unique_labels
#
#
# def find_optimal_breakpoints(
# features: np.ndarray,
# target_segments: int,
# min_size: int = 15
# ) -> List[int]:
# """
# Search breakpoints for K in {target-1, target, target+1} via Dynp,
# enforcing minimum segment length and selecting by Calinski-Harabasz.
# Returns list of end indices.
# """
# n, _ = features.shape
# candidate_K = [k for k in (target_segments - 1, target_segments, target_segments + 1)
# if k >= 2 and k <= n // min_size]
# best_score = -np.inf
# best_bkps: List[int] = []
#
# for K in candidate_K:
# algo = rpt.Dynp(model="l2").fit(features)
# bkps = algo.predict(n_bkps=K - 1)
# seg_lens = np.diff([0] + bkps)
# if seg_lens.min() < min_size:
# continue
# # assign segment ids
# seg_labels = np.zeros(n, dtype=int)
# start = 0
# for idx, bp in enumerate(bkps):
# seg_labels[start:bp] = idx
# start = bp
# score = calinski_harabasz_score(features, seg_labels)
# if score > best_score:
# best_score = score
# best_bkps = bkps
#
# if not best_bkps:
# # fallback: target without size constraint
# algo = rpt.Dynp(model="l2").fit(features)
# best_bkps = algo.predict(n_bkps=target_segments - 1)
#
# return best_bkps
#
#
# def all_segments_from_json(
# embeddings_json: str,
# labels_json: Optional[str] = None,
# min_size: int = 15
# ) -> str:
# """
# If labels_json provided, reduce embeddings to centroid-distances,
# segment via Dynp and select best K. Otherwise legacy CROPS pipeline.
# Returns JSON list of {level, start, end}.
# """
# # Parse embeddings into array
# data = np.asarray(json.loads(embeddings_json), dtype=float)
# N = data.shape[0]
# print(f"[DEBUG] Total points (N): {N}")
#
# # Determine segmentation levels
# if labels_json:
# print("[DEBUG] Using label-based segmentation")
# labels = np.asarray(json.loads(labels_json), dtype=int)
# print(f"[DEBUG] Labels unique: {np.unique(labels)}")
# features, unique_labels = compute_distances_from_centroids(data, labels)
# print(f"[DEBUG] Distance features shape: {features.shape}")
# target = len(unique_labels)
# print(f"[DEBUG] Target segments (cluster count): {target}")
# bkps = find_optimal_breakpoints(features, target, min_size=min_size)
# print(f"[DEBUG] Raw breakpoints: {bkps}")
# levels: List[Tuple[int, List[int]]] = [(target, bkps)]
# else:
# print("[DEBUG] Using legacy velocity-based CROPS segmentation")
# from .qualia_segmenter_highdim import _cosine_velocity, crops_distinct
# vel = _cosine_velocity(data)
# print(f"[DEBUG] Velocity vector length: {len(vel)}")
# levels = crops_distinct(vel, min_size=min_size)
# print(f"[DEBUG] CROPS levels (penalty, breakpoints): {levels}")
#
# # Clip breakpoints to valid range [1, N-1]
# max_idx = N - 1
# clipped_levels: List[Tuple[int, List[int]]] = []
# for lvl, bps in levels:
# clipped = []
# for bp in bps:
# bp_int = int(bp)
# bp_clamped = max(1, min(bp_int, max_idx))
# clipped.append(bp_clamped)
# clipped_levels.append((lvl, clipped))
# print(f"[DEBUG] Clipped levels: {clipped_levels}")
# levels = clipped_levels
#
# # Build segment dicts
# segs: List[dict[str, int]] = []
# for level_idx, (_, bkps) in enumerate(levels):
# prev = 0
# for bp in sorted(bkps):
# seg = {"level": level_idx, "start": prev, "end": bp}
# segs.append(seg)
# print(f"[DEBUG] Added segment: {seg}")
# prev = bp
#
# result_json = json.dumps(segs, separators=(',', ':'))
# print(f"[DEBUG] Final segments JSON: {result_json}")
# return result_json
#
#!/usr/bin/env python3
"""
qualia_segmenter_highdim_hybrid.py — adaptive‑CROPS + hybrid centroid/velocity
==============================================================================
This file **extends** the original *qualia_segmenter_highdim.py* while **keeping
all earlier public APIs and helpers intact**. Nothing was removed; new logic is
only *added* or *augments* existing functions.
New in this build
-----------------
1. **Hybrid representation & feature stream**
• PCA (98 % variance) → optional LDA "emotion tilt" if cluster labels given.
• Concatenate centroid‑distance matrix **and** Savitzky–Golay‑smoothed
cosine‑velocity into one feature matrix.
2. **Windowed smoothing without phase‑lag**
• Replaces the asymmetric 57‑point trailing mean by a **symmetric
Savitzky–Golay filter** (window = 9 days, poly = 3).
3. **Post‑merge tiny fragments** (< `min_size` days) after the first cut to kill
spurious micro‑segments.
Public API functions *recent_qualia_from_json* and *all_segments_from_json*
still take **exactly** the same arguments and return the same JSON payloads.
"""
from __future__ import annotations
# --------------------------------------------------------------------------- #
# Standard & third‑party imports (original + new) #
# --------------------------------------------------------------------------- #
import json
from typing import List, Tuple, Optional
import numpy as np
try:
import ruptures as rpt
except ModuleNotFoundError as e:
raise ModuleNotFoundError(
"qualia_segmenter_highdim_hybrid requires the 'ruptures' package.\n"
"Install it with: pip install ruptures"
) from e
from sklearn.metrics import calinski_harabasz_score
# Added for hybrid representation
from sklearn.decomposition import PCA
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis as LDA
from scipy.signal import savgol_filter
# --------------------------------------------------------------------------- #
# ╔══════════════════════════════════════════════════════════════════════════╗
# ║ ORIGINAL HELPERS (UNMODIFIED) ║
# ╚══════════════════════════════════════════════════════════════════════════╝
# --------------------------------------------------------------------------- #
# Cosine‑velocity #
# --------------------------------------------------------------------------- #
def _semantic_cuts_within(
emb_raw: np.ndarray,
labels_arr: Optional[np.ndarray],
start: int,
end: int,
*,
min_size: int,
tol: float = 0.05,
) -> list[int]:
"""
Propose internal breakpoint indices (GLOBAL) inside [start, end]
using the SAME semantics you use globally:
• hybrid Dynp when labels are present (distance + vel features)
• CROPS+elbow on velocity otherwise.
Returns a (possibly empty) list of GLOBAL bp indices.
"""
seg_len = end - start + 1
if seg_len < 2 * min_size:
return []
emb_seg = emb_raw[start:end + 1]
labels_seg = None if labels_arr is None else labels_arr[start:end + 1]
# Representation + features
emb_h, _pca, _lda = _pca_lda_transform(emb_seg, labels_seg)
feats, uniq = _build_hybrid_features(emb_h, labels_seg)
# Candidate breakpoints (LOCAL indices that are *ends* of segments)
if uniq is not None and len(uniq) > 1:
target = len(uniq)
bkps_local = find_optimal_breakpoints(feats, target, min_size=min_size)
else:
vel = feats[:, -1]
levels = crops_distinct(vel, min_size=min_size)
_, bkps_local = pick_elbow(levels, emb_seg, tol=tol)
# Map to GLOBAL indices and keep only internal breakpoints
bkps_global = [start + int(b) for b in bkps_local if start < start + int(b) < end]
# Enforce min_size within this segment
if not bkps_global:
return []
# Drop any bkps that would create < min_size pieces
candidate = sorted(bkps_global)
pieces = [start] + candidate + [end + 1]
if any((pieces[i+1] - pieces[i]) < min_size for i in range(len(pieces)-1)):
# If proposed cut violates min_size, reject
return []
return candidate
def _refine_long_segments_semantic(
emb_raw: np.ndarray,
labels_arr: Optional[np.ndarray],
segs: list[dict[str, int]],
*,
min_size: int,
max_size: int,
tol: float = 0.05,
) -> list[dict[str, int]]:
"""
For each segment exceeding max_size, try semantic splits.
If no elbow-based split is found, fall back to a single
peak-velocity cut (semantic) provided it respects min_size.
"""
changed = True
while changed:
changed = False
next_segs: list[dict[str, int]] = []
for seg in segs:
s, e = seg["start"], seg["end"]
length = e - s + 1
if length <= max_size:
next_segs.append(seg)
continue
# 1) Try semantic cuts (may return multiple splits)
cuts = _semantic_cuts_within(emb_raw, labels_arr, s, e, min_size=min_size, tol=tol)
# 2) If no semantic elbow, cut at peak velocity once (still semantic)
if not cuts:
emb_seg = emb_raw[s:e+1]
emb_h, _pca, _lda = _pca_lda_transform(emb_seg, None if labels_arr is None else labels_arr[s:e+1])
feats, _ = _build_hybrid_features(emb_h, None if labels_arr is None else labels_arr[s:e+1])
vel = feats[:, -1].ravel()
# avoid borders by min_size
lo, hi = min_size, len(vel) - min_size
if hi > lo:
cut_local = int(np.argmax(vel[lo:hi]) + lo)
cut_global = s + cut_local
# only accept if it helps satisfy the cap
if (cut_global - s + 1) >= min_size and (e - cut_global) + 1 >= min_size:
cuts = [cut_global]
if not cuts:
# nothing we can do; keep as-is
next_segs.append(seg)
continue
# Split along accepted cuts
parts = [s] + sorted(cuts) + [e]
for i in range(len(parts) - 1):
a, b = parts[i], parts[i + 1]
next_segs.append({"level": seg["level"], "start": a, "end": b})
changed = True
segs = next_segs
# If any piece STILL exceeds max_size, iterate (recursive refinement)
if any(seg["end"] - seg["start"] + 1 > max_size for seg in segs):
continue
return segs
def _cosine_velocity(x: np.ndarray) -> np.ndarray:
"""Angular ‘speed’ between consecutive embeddings (verbose)."""
print(f"[cos‑vel] embeddings.shape = {x.shape}")
eps = 1e-12
norms = np.linalg.norm(x, axis=1, keepdims=True) + eps
u = x / norms
cos_sim = np.sum(u[:-1] * u[1:], axis=1)
v = 1.0 - np.clip(cos_sim, -1.0, 1.0)
print(f"[cos‑vel] velocity length={len(v)}, first 5: {v[:5]}")
return v
# --------------------------------------------------------------------------- #
# Adaptive CROPS: *only* distinct breakpoint sets #
# --------------------------------------------------------------------------- #
def crops_distinct(
velocity: np.ndarray,
*,
pen_min: float = 0.5,
pen_max: float = 20.0,
rel_step: float = 0.02,
min_size: int = 15,
model: str = "rbf",
max_levels: int = 200,
) -> List[Tuple[float, List[int]]]:
"""Multiplicative grid search through penalty space yielding *distinct* cuts."""
algo = rpt.Pelt(model=model, min_size=min_size).fit(velocity.reshape(-1, 1))
path: List[Tuple[float, List[int]]] = []
p = pen_min
last_bkps: List[int] | None = None
levels = 0
while p <= pen_max and levels < max_levels:
bkps = algo.predict(pen=p)
if last_bkps is None or bkps != last_bkps:
path.append((p, bkps))
last_bkps = bkps
levels += 1
p *= 1.0 + rel_step
if last_bkps == [len(velocity)]: # single segment left
break
# Debug
print("\n[CROPS‑distinct] Hierarchy (unique levels):")
for p, b in path:
print(f" pen={p:8.3f}{b}")
print("[CROPS‑distinct] ────────────────\n")
return path
# --------------------------------------------------------------------------- #
# Cohesion score #
# --------------------------------------------------------------------------- #
def _cohesion(emb: np.ndarray, bkps: List[int]) -> float:
"""Davies–Bouldin‑style cohesion (∞ if only one segment)."""
if len(bkps) <= 1:
return float("inf")
start, W, M = 0, [], []
for b in bkps:
seg = emb[start:b]
W.append(seg.var(axis=0).mean())
M.append(seg.mean(axis=0))
start = b
intra = float(np.mean(W))
inter = float(np.mean([
np.linalg.norm(a - b)
for i, a in enumerate(M) for j, b in enumerate(M) if j > i]))
return intra / (inter + 1e-9)
def _candidate_ks(n: int, min_size: int, target: int | None = None) -> list[int]:
"""
Return a smart range of K to test.
• always ≥ 2 slices
• never smaller than min_size
• capped at 10 to keep Dynp fast
• if target is given (labels), bias towards ±3 around it
"""
k_max = max(2, min(10, n // min_size))
if target is None or target < 2:
return list(range(2, k_max + 1))
window = range(max(2, target - 3), min(k_max, target + 3) + 1)
# add the extremes 2 and k_max so we don’t miss very coarse/fine cuts
return sorted(set(window).union({2, k_max}))
# --------------------------------------------------------------------------- #
# Choose elbow by largest cohesion drop #
# --------------------------------------------------------------------------- #
def pick_elbow(
levels: List[Tuple[float, List[int]]],
emb: np.ndarray,
tol: float = 0.05,
) -> Tuple[float, List[int]]:
scores = [(_cohesion(emb, b)) for _, b in levels]
print("[cohesion] level → score:")
for (p, _), s in zip(levels, scores):
print(f" {p:8.3f}{s if np.isfinite(s) else '∞'}")
best_idx, best_ratio = None, 1.0
for i in range(len(scores) - 1):
if not (np.isfinite(scores[i]) and np.isfinite(scores[i + 1])):
continue
ratio = scores[i] / scores[i + 1]
if ratio >= 1.0 + tol and ratio > best_ratio:
best_ratio, best_idx = ratio, i + 1 # keep lower‑score side
if best_idx is None:
for i in reversed(range(len(levels))): # finest level with >1 segment
if len(levels[i][1]) > 1:
best_idx = i
break
if best_idx is None:
best_idx = len(levels) - 1
print(f"[cohesion] picked elbow @ pen={levels[best_idx][0]:.3f}, "
f"ratio={best_ratio:.3f}\n")
return levels[best_idx]
# --------------------------------------------------------------------------- #
# Vendored sliding_window_view (NumPy ≥1.20 ships it natively) #
# --------------------------------------------------------------------------- #
try:
from numpy.lib.stride_tricks import sliding_window_view # type: ignore
except ImportError: # <1.20 – vendored replacement for 2‑D arrays
from numpy.lib.stride_tricks import as_strided # type: ignore
def sliding_window_view(x: np.ndarray, window_shape):
"""Simple 2‑D sliding window replacement."""
w, D = window_shape
T, D0 = x.shape
if D0 != D:
raise ValueError("sliding_window_view: dimension mismatch")
if T < w:
return np.empty((0, w, D), dtype=x.dtype)
shape = (T - w + 1, w, D)
strides = (x.strides[0], x.strides[0], x.strides[1])
return as_strided(x, shape=shape, strides=strides)
# --------------------------------------------------------------------------- #
# Recent‑segment extractor #
# --------------------------------------------------------------------------- #
def _recent_slice(
embeddings: np.ndarray,
*,
pen_min: float = 0.5,
pen_max: float = 20.0,
rel_step: float = 0.02,
min_size: int = 5,
model: str = "rbf",
tol: float = 0.05,
) -> Tuple[np.ndarray, int, List[int]]:
"""Returns (recent_slice, start_idx, chosen_breakpoints) for *legacy* path."""
if embeddings.shape[0] < 2:
return np.empty((0, embeddings.shape[1])), 0, [0]
# Symmetric 57‑day smoothing (unchanged)
win = 57
pad = win // 2
emb_pad = np.pad(embeddings, [(pad, pad), (0, 0)], mode="edge")
embeddings_smooth = sliding_window_view(emb_pad, (win, embeddings.shape[1])).mean(axis=2)
velocity = _cosine_velocity(embeddings_smooth)
levels = crops_distinct(
velocity,
pen_min=pen_min,
pen_max=pen_max,
rel_step=rel_step,
min_size=min_size,
model=model,
)
_pen_sel, bkps_sel = pick_elbow(levels, embeddings, tol=tol)
start_idx = bkps_sel[-2] + 1 if len(bkps_sel) >= 2 else 0
return embeddings[start_idx:].copy(), start_idx, bkps_sel
# --------------------------------------------------------------------------- #
# Public legacy API #
# --------------------------------------------------------------------------- #
def recent_qualia_from_json(
embeddings_json: str,
*,
pen_min: float = 0.5,
pen_max: float = 20.0,
rel_step: float = 0.02,
min_size: int = 5,
model: str = "rbf",
tol: float = 0.05,
) -> str:
"""Legacy API — unchanged."""
print("[API] Parsing embeddings JSON …")
try:
arr = np.asarray(json.loads(embeddings_json), dtype=float)
except Exception as exc:
raise ValueError("Failed to parse embeddings_json — must be JSON list of lists.") from exc
if arr.ndim != 2:
raise ValueError("Input embeddings must be 2‑D.")
recent, start_idx, bkps = _recent_slice(
arr,
pen_min=pen_min,
pen_max=pen_max,
rel_step=rel_step,
min_size=min_size,
model=model,
tol=tol,
)
result = {
"recent_embeddings": recent.tolist(),
"start_idx": int(start_idx),
"breakpoints": [int(i) for i in bkps],
}
return json.dumps(result, separators=(",", ":"))
# --------------------------------------------------------------------------- #
# Centroid distance helper #
# --------------------------------------------------------------------------- #
def compute_distances_from_centroids(
embeddings: np.ndarray, labels: np.ndarray
) -> Tuple[np.ndarray, np.ndarray]:
unique_labels = np.unique(labels)
centroids = np.vstack([
embeddings[labels == c].mean(axis=0) for c in unique_labels
])
dists = np.linalg.norm(embeddings[:, None, :] - centroids[None, :, :], axis=2)
return dists, unique_labels
# --------------------------------------------------------------------------- #
# Dynp breakpoint search (original) #
# --------------------------------------------------------------------------- #
def find_optimal_breakpoints(
features: np.ndarray,
target_segments: int,
*,
min_size: int = 15,
) -> List[int]:
n, _ = features.shape
candidate_K = [k for k in (target_segments - 1, target_segments, target_segments + 1)
if k >= 2 and k <= n // min_size]
best_score, best_bkps = -np.inf, []
for K in candidate_K:
algo = rpt.Dynp(model="l2").fit(features)
bkps = algo.predict(n_bkps=K - 1)
if np.min(np.diff([0] + bkps)) < min_size:
continue
seg_labels = np.zeros(n, dtype=int)
start = 0
for idx, bp in enumerate(bkps):
seg_labels[start:bp] = idx
start = bp
score = calinski_harabasz_score(features, seg_labels)
if score > best_score:
best_score, best_bkps = score, bkps
if not best_bkps: # fallback no size constraint
algo = rpt.Dynp(model="l2").fit(features)
best_bkps = algo.predict(n_bkps=target_segments - 1)
return best_bkps
# ╔══════════════════════════════════════════════════════════════════════════╗
# ║ ███ H Y B R I D E X T E N S I O N ███ ║
# ╚══════════════════════════════════════════════════════════════════════════╝
# --------------------------------------------------------------------------- #
# Hybrid representation (PCA → optional LDA) #
# --------------------------------------------------------------------------- #
def _pca_lda_transform(
emb: np.ndarray,
labels: Optional[np.ndarray] = None,
*,
var_keep: float = 0.98,
) -> Tuple[np.ndarray, PCA, Optional[LDA]]:
print(f"[hyb] PCA→LDA transform: input shape {emb.shape}")
pca = PCA(n_components=var_keep, whiten=True, svd_solver="full")
emb_pca = pca.fit_transform(emb)
print(f"[hyb] PCA kept {emb_pca.shape[1]} dims (≈{var_keep:.0%} var)")
lda_model: Optional[LDA] = None
if labels is not None and len(np.unique(labels)) > 1:
lda_model = LDA()
emb_final = lda_model.fit_transform(emb_pca, labels).astype(float)
print(f"[hyb] LDA projected to {emb_final.shape[1]} dims")
else:
emb_final = emb_pca.astype(float)
print("[hyb] LDA skipped")
return emb_final, pca, lda_model
# --------------------------------------------------------------------------- #
# Build hybrid feature matrix #
# --------------------------------------------------------------------------- #
def _build_hybrid_features(
emb_h: np.ndarray,
labels: Optional[np.ndarray] = None,
) -> Tuple[np.ndarray, Optional[np.ndarray]]:
# centroid distances (cluster aware)
if labels is not None and len(np.unique(labels)) >= 2:
dists, uniq = compute_distances_from_centroids(emb_h, labels)
print(f"[hyb] dists shape {dists.shape}")
else:
dists = np.zeros((emb_h.shape[0], 1))
uniq = None
print("[hyb] centroid distances skipped")
# cosine velocity with Savitzky‑Golay smoothing
raw_vel = _cosine_velocity(emb_h)
raw_vel = np.r_[raw_vel[0], raw_vel] # pad front
vel = savgol_filter(raw_vel, window_length=9, polyorder=3, mode="interp").reshape(-1, 1)
features = np.hstack([dists, vel])
print(f"[hyb] features matrix {features.shape}")
return features, uniq
# --------------------------------------------------------------------------- #
# Post‑merge tiny segments #
# --------------------------------------------------------------------------- #
def _merge_small_segments(bkps: List[int], n_points: int, *, min_size: int) -> List[int]:
if not bkps:
return bkps
seg_starts = [0] + bkps[:-1]
seg_ends = bkps.copy()
seg_sizes = [e - s for s, e in zip(seg_starts, seg_ends)]
changed = True
while changed and len(seg_sizes) > 1:
changed = False
for idx, size in enumerate(seg_sizes):
# skip merging tail segment to preserve last small slice
if idx == len(seg_sizes) - 1:
continue
if size >= min_size:
continue
# pick neighbour with larger size
if idx == 0:
merge_with = 1
else:
left = seg_sizes[idx - 1]
right = seg_sizes[idx + 1]
merge_with = idx - 1 if left >= right else idx + 1
# avoid merging into tail
if merge_with == len(seg_sizes) - 1:
merge_with = idx - 1
# drop the breakpoint that separates the two
del seg_ends[max(idx, merge_with)]
seg_starts = [0] + seg_ends[:-1]
seg_sizes = [e - s for s, e in zip(seg_starts, seg_ends)]
changed = True
print(f"[hyb-merge] merged seg {idx} into {merge_with}, new seg_sizes {seg_sizes}")
break
return seg_ends
# --------------------------------------------------------------------------- #
# ★★★ HYBRID VERSION OF all_segments_from_json ★★★ #
# --------------------------------------------------------------------------- #
def all_segments_from_json(
embeddings_json: str,
labels_json: Optional[str] = None,
*,
min_size: int = 15,
max_size: int = 42
) -> str:
"""Drop‑in replacement that prefers the hybrid path when labels provided."""
emb_raw = np.asarray(json.loads(embeddings_json), dtype=float)
N = emb_raw.shape[0]
if emb_raw.ndim != 2:
raise ValueError("Input embeddings must be 2‑D.")
labels_arr: Optional[np.ndarray] = None
if labels_json is not None:
labels_arr = np.asarray(json.loads(labels_json), dtype=int)
if len(labels_arr) != N:
raise ValueError("labels_json length does not match embeddings")
# representation
emb_h, _pca, _lda = _pca_lda_transform(emb_raw, labels_arr)
# feature matrix
features_h, uniq = _build_hybrid_features(emb_h, labels_arr)
# segmentation
if uniq is not None and len(uniq) > 1:
target = len(uniq)
print(f"[API] Dynp target={target}")
bkps = find_optimal_breakpoints(features_h, target, min_size=min_size)
else:
print("[API] Legacy CROPS path")
vel = features_h[:, -1]
levels = crops_distinct(vel, min_size=min_size)
_, bkps = pick_elbow(levels, emb_raw)
bkps = sorted(int(b) for b in bkps if 0 < b < N)
print(f"[API] initial bkps {bkps}")
bkps = _merge_small_segments(bkps, N, min_size=min_size)
print(f"[API] bkps after merge {bkps}")
# build seg dict list
segs: List[dict[str, int]] = []
prev = 0
for bp in bkps:
segs.append({"level": 0, "start": prev, "end": bp})
prev = bp
# (tail segment omitted to match legacy behavior) or, if desired, include capped tail:
if prev < N:
segs.append({"level": 0, "start": prev, "end": N-1})
segs = _refine_long_segments_semantic(
emb_raw, labels_arr, segs, min_size=min_size, max_size=max_size, tol=0.05
)
return json.dumps(segs, separators=(",", ":"))
def all_segments_from_java(
embeddings: list[list[float]],
labels: list[int] | None = None,
*,
min_size: int = 14,
max_size: int = 42
) -> str:
"""
Like all_segments_from_json, but accepts native Python lists
(passed by Chaquopy from Java) to avoid any JSON buffering
on the Java heap.
"""
# 1) convert to numpy arrays
emb_raw = np.asarray(embeddings, dtype=float)
N = emb_raw.shape[0]
if emb_raw.ndim != 2:
raise ValueError("Input embeddings must be 2‑D.")
labels_arr = None
if labels is not None:
labels_arr = np.asarray(labels, dtype=int)
if labels_arr.shape[0] != N:
raise ValueError("labels length does not match embeddings")
# 2) exactly the same hybrid logic you already have:
emb_h, _pca, _lda = _pca_lda_transform(emb_raw, labels_arr)
features_h, uniq = _build_hybrid_features(emb_h, labels_arr)
if uniq is not None and len(uniq) > 1:
target = len(uniq)
bkps = find_optimal_breakpoints(features_h, target, min_size=min_size)
else:
vel = features_h[:, -1]
levels = crops_distinct(vel, min_size=min_size)
_, bkps = pick_elbow(levels, emb_raw)
# 3) finalize & merge small segments
bkps = sorted(int(b) for b in bkps if 0 < b < N)
bkps = _merge_small_segments(bkps, N, min_size=min_size)
# 4) build the JSON response
segs: list[dict[str,int]] = []
prev = 0
for bp in bkps:
segs.append({"level": 0, "start": prev, "end": bp})
prev = bp
if prev < N:
segs.append({"level": 0, "start": prev, "end": N - 1})
segs = _refine_long_segments_semantic(
emb_raw, labels_arr, segs, min_size=min_size, max_size=max_size, tol=0.05
)
# return exactly the same JSON as before
return json.dumps(segs, separators=(",", ":"))