physicausal 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.
- physicausal/__init__.py +25 -0
- physicausal/benchmarks/__init__.py +3 -0
- physicausal/benchmarks/causalverse.py +142 -0
- physicausal/benchmarks/causalworld.py +122 -0
- physicausal/causal/__init__.py +4 -0
- physicausal/causal/intervention.py +47 -0
- physicausal/causal/validator.py +315 -0
- physicausal/envs/__init__.py +4 -0
- physicausal/envs/pendulum.py +96 -0
- physicausal/envs/simple_push.py +261 -0
- physicausal/models/__init__.py +5 -0
- physicausal/models/base.py +123 -0
- physicausal/models/beta_tc_vae.py +224 -0
- physicausal/models/losses.py +18 -0
- physicausal/models/vae.py +250 -0
- physicausal/training/__init__.py +3 -0
- physicausal/training/agent.py +238 -0
- physicausal/training/hparams.py +193 -0
- physicausal/utils/__init__.py +3 -0
- physicausal/utils/tracker.py +123 -0
- physicausal-0.1.0.dist-info/METADATA +377 -0
- physicausal-0.1.0.dist-info/RECORD +25 -0
- physicausal-0.1.0.dist-info/WHEEL +5 -0
- physicausal-0.1.0.dist-info/licenses/LICENSE +21 -0
- physicausal-0.1.0.dist-info/top_level.txt +1 -0
physicausal/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .causal.validator import CausalValidator
|
|
2
|
+
from .envs.pendulum import PendulumEnv, PendulumState
|
|
3
|
+
from .envs.simple_push import ObjectState, SimplePushEnv
|
|
4
|
+
from .models.beta_tc_vae import BetaTCVAE
|
|
5
|
+
from .models.vae import BetaVAE, WorldModel
|
|
6
|
+
from .training.agent import CausalLearningAgent, ReplayBuffer, train
|
|
7
|
+
from .utils.tracker import NoOpTracker, Tracker
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BetaTCVAE",
|
|
11
|
+
"BetaVAE",
|
|
12
|
+
"CausalLearningAgent",
|
|
13
|
+
"CausalValidator",
|
|
14
|
+
"NoOpTracker",
|
|
15
|
+
"ObjectState",
|
|
16
|
+
"PendulumEnv",
|
|
17
|
+
"PendulumState",
|
|
18
|
+
"ReplayBuffer",
|
|
19
|
+
"SimplePushEnv",
|
|
20
|
+
"Tracker",
|
|
21
|
+
"WorldModel",
|
|
22
|
+
"train",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CausalVerse data format adapter.
|
|
3
|
+
|
|
4
|
+
CausalVerse (NeurIPS 2025 Spotlight) is a comprehensive causal representation learning benchmark,
|
|
5
|
+
containing 10 domains, 1024x1024 images, 300M+ video frames.
|
|
6
|
+
|
|
7
|
+
Its data structure:
|
|
8
|
+
- images: (T, H, W, 3) RGB sequence
|
|
9
|
+
- factors: dict of ground-truth causal factors
|
|
10
|
+
- actions: (T-1, action_dim) action sequence
|
|
11
|
+
|
|
12
|
+
This adapter provides:
|
|
13
|
+
1. Converting CausalVerse video data to physicausal (obs, action, next_obs) format
|
|
14
|
+
2. Converting physicausal evaluation results to CausalVerse standard metrics (MCC, R2)
|
|
15
|
+
|
|
16
|
+
Note: CausalVerse requires additional installation (not a physicausal core dependency).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def convert_causalverse_sequence(
|
|
26
|
+
images: np.ndarray,
|
|
27
|
+
actions: np.ndarray,
|
|
28
|
+
factors: dict[str, np.ndarray] | None = None,
|
|
29
|
+
resize_fn: Callable | None = None,
|
|
30
|
+
) -> list[tuple[dict, np.ndarray, dict]]:
|
|
31
|
+
"""
|
|
32
|
+
Convert CausalVerse video sequence to physicausal triplet list.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
images : np.ndarray
|
|
37
|
+
Image sequence, shape (T, H, W, 3).
|
|
38
|
+
actions : np.ndarray
|
|
39
|
+
Action sequence, shape (T-1, action_dim).
|
|
40
|
+
factors : dict, optional
|
|
41
|
+
Ground-truth causal factors for validation.
|
|
42
|
+
resize_fn : callable, optional
|
|
43
|
+
Image resize function for dimensionality reduction.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
data : list
|
|
48
|
+
physicausal-format (obs, action, next_obs) list.
|
|
49
|
+
"""
|
|
50
|
+
T = images.shape[0]
|
|
51
|
+
data = []
|
|
52
|
+
|
|
53
|
+
for t in range(T - 1):
|
|
54
|
+
obs_b = _image_to_obs(images[t], factors, t, resize_fn)
|
|
55
|
+
obs_a = _image_to_obs(images[t + 1], factors, t + 1, resize_fn)
|
|
56
|
+
action = np.array(actions[t], dtype=np.float32)
|
|
57
|
+
data.append((obs_b, action, obs_a))
|
|
58
|
+
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _image_to_obs(
|
|
63
|
+
image: np.ndarray,
|
|
64
|
+
factors: dict | None,
|
|
65
|
+
frame_idx: int,
|
|
66
|
+
resize_fn: Callable | None,
|
|
67
|
+
) -> dict:
|
|
68
|
+
"""Convert single frame to physicausal observation format."""
|
|
69
|
+
if resize_fn is not None:
|
|
70
|
+
image = resize_fn(image)
|
|
71
|
+
|
|
72
|
+
visual = image.flatten().astype(np.float32) / 255.0
|
|
73
|
+
|
|
74
|
+
obs: dict[str, Any] = {"visual": visual}
|
|
75
|
+
|
|
76
|
+
if factors is not None:
|
|
77
|
+
# Extract factor values for current frame
|
|
78
|
+
for key, values in factors.items():
|
|
79
|
+
if frame_idx < len(values):
|
|
80
|
+
obs[key] = values[frame_idx]
|
|
81
|
+
|
|
82
|
+
return obs
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def compute_causalverse_metrics(
|
|
86
|
+
zs: np.ndarray,
|
|
87
|
+
ground_truth_factors: np.ndarray,
|
|
88
|
+
) -> dict[str, float]:
|
|
89
|
+
"""
|
|
90
|
+
Compute CausalVerse standard metrics.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
zs : np.ndarray
|
|
95
|
+
Latent variables, shape (n_samples, latent_dim).
|
|
96
|
+
ground_truth_factors : np.ndarray
|
|
97
|
+
Ground-truth causal factors, shape (n_samples, n_factors).
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
metrics : dict
|
|
102
|
+
Contains MCC (Mean Correlation Coefficient) and R2.
|
|
103
|
+
"""
|
|
104
|
+
from scipy import stats
|
|
105
|
+
|
|
106
|
+
n_factors = ground_truth_factors.shape[1]
|
|
107
|
+
n_dims = zs.shape[1]
|
|
108
|
+
|
|
109
|
+
# Compute correlation matrix for all (factor, latent_dim) pairs
|
|
110
|
+
corr_matrix = np.zeros((n_factors, n_dims))
|
|
111
|
+
for i in range(n_factors):
|
|
112
|
+
for j in range(n_dims):
|
|
113
|
+
r, _ = stats.pearsonr(ground_truth_factors[:, i], zs[:, j])
|
|
114
|
+
corr_matrix[i, j] = abs(r)
|
|
115
|
+
|
|
116
|
+
# MCC: optimal one-to-one matching between factors and latent dims
|
|
117
|
+
# (Hungarian algorithm on the |correlation| matrix, standard in CRL literature)
|
|
118
|
+
from scipy.optimize import linear_sum_assignment
|
|
119
|
+
|
|
120
|
+
rows, cols = linear_sum_assignment(-corr_matrix)
|
|
121
|
+
matched_corrs = [corr_matrix[i, j] for i, j in zip(rows, cols)]
|
|
122
|
+
|
|
123
|
+
mcc = np.mean(matched_corrs) if matched_corrs else 0.0
|
|
124
|
+
|
|
125
|
+
# R2: linear regression fit
|
|
126
|
+
from sklearn.linear_model import LinearRegression
|
|
127
|
+
from sklearn.metrics import r2_score
|
|
128
|
+
|
|
129
|
+
r2s = []
|
|
130
|
+
for i in range(n_factors):
|
|
131
|
+
reg = LinearRegression()
|
|
132
|
+
reg.fit(zs, ground_truth_factors[:, i])
|
|
133
|
+
pred = reg.predict(zs)
|
|
134
|
+
r2s.append(r2_score(ground_truth_factors[:, i], pred))
|
|
135
|
+
|
|
136
|
+
avg_r2 = np.mean(r2s) if r2s else 0.0
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
"mcc": float(mcc),
|
|
140
|
+
"r2": float(avg_r2),
|
|
141
|
+
"matched_corrs": matched_corrs,
|
|
142
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CausalWorld data format adapter.
|
|
3
|
+
|
|
4
|
+
CausalWorld (https://github.com/rr-learning/CausalWorld) uses PyBullet
|
|
5
|
+
for robot manipulation simulation. Its data structure is:
|
|
6
|
+
- observations: dict with 'goal', 'mask', 'rgb', 'depth'
|
|
7
|
+
- actions: continuous action vector
|
|
8
|
+
- rewards: scalar reward
|
|
9
|
+
|
|
10
|
+
This adapter provides:
|
|
11
|
+
1. Converting CausalWorld data to physicausal format
|
|
12
|
+
2. Converting physicausal evaluation results to CausalWorld metrics
|
|
13
|
+
|
|
14
|
+
Note: CausalWorld requires additional installation (not a physicausal core dependency).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def convert_causalworld_triplet(
|
|
24
|
+
obs_before: dict,
|
|
25
|
+
action: np.ndarray,
|
|
26
|
+
obs_after: dict,
|
|
27
|
+
extract_visual_fn: Callable | None = None,
|
|
28
|
+
) -> tuple[dict, np.ndarray, dict]:
|
|
29
|
+
"""
|
|
30
|
+
Convert one CausalWorld (obs, action, next_obs) to physicausal format.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
obs_before : dict
|
|
35
|
+
CausalWorld observation containing 'rgb', 'depth', 'mask', etc.
|
|
36
|
+
action : np.ndarray
|
|
37
|
+
Action vector.
|
|
38
|
+
obs_after : dict
|
|
39
|
+
Next observation.
|
|
40
|
+
extract_visual_fn : callable, optional
|
|
41
|
+
Custom visual feature extraction function. Defaults to flattening RGB.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
(obs_before_fmt, action_fmt, obs_after_fmt) : tuple
|
|
46
|
+
physicausal-format triplet.
|
|
47
|
+
"""
|
|
48
|
+
if extract_visual_fn is None:
|
|
49
|
+
# Default: flatten RGB image as visual feature
|
|
50
|
+
def extract_visual_fn(obs: dict) -> np.ndarray:
|
|
51
|
+
rgb = obs.get("rgb", obs.get("observation", {}).get("rgb"))
|
|
52
|
+
if rgb is None:
|
|
53
|
+
raise ValueError("Cannot find 'rgb' in observation")
|
|
54
|
+
return rgb.flatten().astype(np.float32) / 255.0
|
|
55
|
+
|
|
56
|
+
obs_b_fmt = {
|
|
57
|
+
"visual": extract_visual_fn(obs_before),
|
|
58
|
+
"position": obs_before.get("position", np.zeros(3)),
|
|
59
|
+
}
|
|
60
|
+
obs_a_fmt = {
|
|
61
|
+
"visual": extract_visual_fn(obs_after),
|
|
62
|
+
"position": obs_after.get("position", np.zeros(3)),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return obs_b_fmt, np.array(action, dtype=np.float32), obs_a_fmt
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def causalworld_to_physicausal(
|
|
69
|
+
dataset: list[dict],
|
|
70
|
+
extract_visual_fn: Callable | None = None,
|
|
71
|
+
) -> list[tuple[dict, np.ndarray, dict]]:
|
|
72
|
+
"""
|
|
73
|
+
Batch convert CausalWorld dataset to physicausal format.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
dataset : list[dict]
|
|
78
|
+
CausalWorld dataset, each item is {"obs": ..., "action": ..., "next_obs": ...}.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
data : list
|
|
83
|
+
physicausal-format data list.
|
|
84
|
+
"""
|
|
85
|
+
data = []
|
|
86
|
+
for item in dataset:
|
|
87
|
+
obs_b = item.get("obs", item.get("observation"))
|
|
88
|
+
action = item.get("action")
|
|
89
|
+
obs_a = item.get("next_obs", item.get("next_observation"))
|
|
90
|
+
triplet = convert_causalworld_triplet(obs_b, action, obs_a, extract_visual_fn)
|
|
91
|
+
data.append(triplet)
|
|
92
|
+
return data
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def compute_causalworld_metrics(
|
|
96
|
+
validator_results: dict[str, Any],
|
|
97
|
+
) -> dict[str, float]:
|
|
98
|
+
"""
|
|
99
|
+
Convert physicausal validation results to CausalWorld-style metrics.
|
|
100
|
+
|
|
101
|
+
CausalWorld uses the following metrics:
|
|
102
|
+
- success_rate: task success rate
|
|
103
|
+
- intervention_success: intervention success rate
|
|
104
|
+
- disentanglement_score: disentanglement score
|
|
105
|
+
|
|
106
|
+
This function provides approximate mapping.
|
|
107
|
+
"""
|
|
108
|
+
results = validator_results.get("results", {})
|
|
109
|
+
|
|
110
|
+
# Compute average causal score
|
|
111
|
+
causal_scores = [
|
|
112
|
+
res["best_abs_corr"] for res in results.values() if res.get("is_causal")
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
avg_corr = np.mean(causal_scores) if causal_scores else 0.0
|
|
116
|
+
n_causal = sum(1 for r in results.values() if r.get("is_causal"))
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"disentanglement_score": avg_corr,
|
|
120
|
+
"n_causal_properties": n_causal,
|
|
121
|
+
"total_properties": len(results),
|
|
122
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
因果干预与评估工具。
|
|
3
|
+
|
|
4
|
+
此模块保留向后兼容的 API,主要功能已迁移到 CausalValidator。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def bootstrap_ci(
|
|
13
|
+
data: np.ndarray,
|
|
14
|
+
statistic_fn: Callable[[np.ndarray], float],
|
|
15
|
+
n_bootstrap: int = 1000,
|
|
16
|
+
alpha: float = 0.05,
|
|
17
|
+
seed: int = 42,
|
|
18
|
+
) -> tuple[float, float]:
|
|
19
|
+
"""
|
|
20
|
+
Bootstrap 置信区间估计。
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
data : np.ndarray
|
|
25
|
+
原始样本数据。
|
|
26
|
+
statistic_fn : callable
|
|
27
|
+
统计量计算函数,输入数组返回标量。
|
|
28
|
+
n_bootstrap : int
|
|
29
|
+
Bootstrap 重采样次数。
|
|
30
|
+
alpha : float
|
|
31
|
+
显著性水平。
|
|
32
|
+
seed : int
|
|
33
|
+
随机种子。
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
(lower, upper) : 置信区间上下界。
|
|
38
|
+
"""
|
|
39
|
+
rng = np.random.RandomState(seed)
|
|
40
|
+
n = len(data)
|
|
41
|
+
stats_ = np.zeros(n_bootstrap)
|
|
42
|
+
for i in range(n_bootstrap):
|
|
43
|
+
sample = data[rng.randint(0, n, n)]
|
|
44
|
+
stats_[i] = statistic_fn(sample)
|
|
45
|
+
lower = np.percentile(stats_, 100 * alpha / 2)
|
|
46
|
+
upper = np.percentile(stats_, 100 * (1 - alpha / 2))
|
|
47
|
+
return float(lower), float(upper)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import torch
|
|
5
|
+
from scipy import stats
|
|
6
|
+
from sklearn.feature_selection import mutual_info_regression
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..models.base import CausalWorldModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CausalValidator:
|
|
13
|
+
"""
|
|
14
|
+
Causal validator: evaluates whether a world model's latent variables capture real physical causal properties.
|
|
15
|
+
|
|
16
|
+
Supports the following validation methods:
|
|
17
|
+
1. Pearson correlation + permutation test (statistical significance)
|
|
18
|
+
2. Mutual information (non-linear association)
|
|
19
|
+
3. Do-operator intervention (causal consistency)
|
|
20
|
+
4. Sensitivity analysis (gradient attribution)
|
|
21
|
+
5. DCS (Disentanglement Completeness Score)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, model: "CausalWorldModel", env):
|
|
25
|
+
self.model = model
|
|
26
|
+
self.env = env
|
|
27
|
+
|
|
28
|
+
# ------------------------------------------------------------------
|
|
29
|
+
# Encoding
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def encode_objects(
|
|
33
|
+
self, objects: list, batch_size: int = 64
|
|
34
|
+
) -> np.ndarray:
|
|
35
|
+
"""
|
|
36
|
+
Encode a list of objects into a latent variable matrix.
|
|
37
|
+
|
|
38
|
+
Each object is probed with a standardized interaction (``env.probe``) whose
|
|
39
|
+
response is a deterministic function of the object's physical properties.
|
|
40
|
+
The probe observations are encoded and averaged into a single latent vector.
|
|
41
|
+
|
|
42
|
+
For probabilistic models the posterior mean (``encode_dist``) is used instead
|
|
43
|
+
of a sampled ``z``, avoiding sampling noise in the correlation estimates.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
zs : np.ndarray
|
|
48
|
+
shape (n_objects, latent_dim)
|
|
49
|
+
"""
|
|
50
|
+
device = next(self.model.parameters()).device
|
|
51
|
+
zs: list[np.ndarray] = []
|
|
52
|
+
|
|
53
|
+
for i in range(0, len(objects), batch_size):
|
|
54
|
+
batch = objects[i : i + batch_size]
|
|
55
|
+
batch_zs: list[np.ndarray] = []
|
|
56
|
+
for obj in batch:
|
|
57
|
+
obj_idx = self.env.objects.index(obj)
|
|
58
|
+
obs_list = self.env.probe(obj_idx)
|
|
59
|
+
obs_t = torch.tensor(
|
|
60
|
+
np.stack([o["visual"] for o in obs_list]),
|
|
61
|
+
dtype=torch.float32,
|
|
62
|
+
device=device,
|
|
63
|
+
)
|
|
64
|
+
with torch.no_grad():
|
|
65
|
+
if hasattr(self.model, "encode_dist"):
|
|
66
|
+
mu, _ = self.model.encode_dist(obs_t)
|
|
67
|
+
z_obj = mu.mean(dim=0)
|
|
68
|
+
else:
|
|
69
|
+
z_obj = self.model.encode(obs_t).mean(dim=0)
|
|
70
|
+
batch_zs.append(z_obj.cpu().numpy())
|
|
71
|
+
zs.append(np.stack(batch_zs, axis=0))
|
|
72
|
+
|
|
73
|
+
return np.concatenate(zs, axis=0)
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Single property validation
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def test_property(
|
|
80
|
+
self,
|
|
81
|
+
objects: list,
|
|
82
|
+
property_name: str = "mass",
|
|
83
|
+
batch_size: int = 64,
|
|
84
|
+
n_perm: int = 5000,
|
|
85
|
+
) -> dict[str, Any]:
|
|
86
|
+
"""
|
|
87
|
+
Test association between latent variables and a physical property.
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
dict containing:
|
|
92
|
+
- best_dim: index of most correlated latent dimension
|
|
93
|
+
- best_corr: Pearson r
|
|
94
|
+
- best_abs_corr: |r|
|
|
95
|
+
- best_pvalue: two-tailed p
|
|
96
|
+
- permutation_pvalue: permutation test p
|
|
97
|
+
- best_mi: mutual information
|
|
98
|
+
- is_causal: passes causal threshold (|r| > 0.5 and perm_p < 0.05)
|
|
99
|
+
- all_corrs: list of correlations for all dimensions
|
|
100
|
+
"""
|
|
101
|
+
zs = self.encode_objects(objects, batch_size=batch_size)
|
|
102
|
+
properties = np.array(
|
|
103
|
+
[getattr(obj, property_name) for obj in objects], dtype=np.float64
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
n_dims = zs.shape[1]
|
|
107
|
+
correlations = []
|
|
108
|
+
for i in range(n_dims):
|
|
109
|
+
r, p = stats.pearsonr(zs[:, i], properties)
|
|
110
|
+
correlations.append((i, float(r), float(abs(r)), float(p)))
|
|
111
|
+
|
|
112
|
+
# Sort by |r|
|
|
113
|
+
correlations.sort(key=lambda x: x[2], reverse=True)
|
|
114
|
+
best = correlations[0]
|
|
115
|
+
|
|
116
|
+
perm_p = self._permutation_test(zs[:, best[0]], properties, n_perm=n_perm)
|
|
117
|
+
|
|
118
|
+
n_neighbors = min(3, max(1, len(objects) - 1))
|
|
119
|
+
mi = mutual_info_regression(
|
|
120
|
+
zs[:, best[0] : best[0] + 1], properties, n_neighbors=n_neighbors
|
|
121
|
+
)[0]
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"property": property_name,
|
|
125
|
+
"best_dim": best[0],
|
|
126
|
+
"best_corr": best[1],
|
|
127
|
+
"best_abs_corr": best[2],
|
|
128
|
+
"best_pvalue": best[3],
|
|
129
|
+
"permutation_pvalue": perm_p,
|
|
130
|
+
"best_mi": float(mi),
|
|
131
|
+
"is_causal": best[2] > 0.5 and perm_p < 0.05,
|
|
132
|
+
"n_objects": len(objects),
|
|
133
|
+
"n_dims": n_dims,
|
|
134
|
+
"all_corrs": correlations,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
def test_multiple_properties(
|
|
138
|
+
self,
|
|
139
|
+
objects: list,
|
|
140
|
+
properties: list[str],
|
|
141
|
+
batch_size: int = 64,
|
|
142
|
+
n_perm: int = 5000,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""Validate multiple physical properties in batch."""
|
|
145
|
+
results = {}
|
|
146
|
+
for prop in properties:
|
|
147
|
+
results[prop] = self.test_property(
|
|
148
|
+
objects, prop, batch_size, n_perm
|
|
149
|
+
)
|
|
150
|
+
return {"results": results, "properties": properties}
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# Do-operator intervention
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def intervene(
|
|
157
|
+
self,
|
|
158
|
+
obj,
|
|
159
|
+
latent_idx: int,
|
|
160
|
+
value: float,
|
|
161
|
+
action: list[float],
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
"""
|
|
164
|
+
Perform do-operator intervention on latent variable.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
obj : ObjectState
|
|
169
|
+
Target object.
|
|
170
|
+
latent_idx : int
|
|
171
|
+
Latent dimension index to intervene on (note: not latent_dim size).
|
|
172
|
+
value : float
|
|
173
|
+
Intervention value.
|
|
174
|
+
action : list[float]
|
|
175
|
+
Action vector.
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
dict containing predicted displacement, original/intervened latents, etc.
|
|
180
|
+
"""
|
|
181
|
+
device = next(self.model.parameters()).device
|
|
182
|
+
|
|
183
|
+
with torch.no_grad():
|
|
184
|
+
obs = self.env._get_observation(obj)
|
|
185
|
+
obs_t = torch.tensor(
|
|
186
|
+
obs["visual"], dtype=torch.float32, device=device
|
|
187
|
+
).unsqueeze(0)
|
|
188
|
+
z_orig = self.model.encode(obs_t).squeeze(0).cpu().numpy()
|
|
189
|
+
|
|
190
|
+
z_int = z_orig.copy()
|
|
191
|
+
z_int[latent_idx] = float(value)
|
|
192
|
+
|
|
193
|
+
with torch.no_grad():
|
|
194
|
+
z_t = torch.tensor(
|
|
195
|
+
z_int, dtype=torch.float32, device=device
|
|
196
|
+
).unsqueeze(0)
|
|
197
|
+
a_t = torch.tensor(
|
|
198
|
+
action, dtype=torch.float32, device=device
|
|
199
|
+
).unsqueeze(0)
|
|
200
|
+
pred_obs = self.model.decode(z_t, a_t)
|
|
201
|
+
|
|
202
|
+
pred_disp = float(
|
|
203
|
+
torch.norm(
|
|
204
|
+
pred_obs.squeeze()[:3]
|
|
205
|
+
- torch.tensor(obj.position, device=device)
|
|
206
|
+
).item()
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"predicted_displacement": pred_disp,
|
|
211
|
+
"z_orig": z_orig,
|
|
212
|
+
"z_intervened": z_int,
|
|
213
|
+
"intervention_dim": latent_idx,
|
|
214
|
+
"intervention_value": value,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
def sweep_interventions(
|
|
218
|
+
self, obj, action: list[float], values: list[float] | None = None
|
|
219
|
+
) -> list[dict]:
|
|
220
|
+
"""
|
|
221
|
+
Sweep interventions across all latent dimensions.
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
values : list[float], optional
|
|
226
|
+
Intervention value for each dimension. Defaults to 2.0 for all.
|
|
227
|
+
"""
|
|
228
|
+
n_dims = self.model.latent_dim
|
|
229
|
+
if values is None:
|
|
230
|
+
values = [2.0] * n_dims
|
|
231
|
+
return [
|
|
232
|
+
self.intervene(obj, d, values[d], action) for d in range(n_dims)
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
# ------------------------------------------------------------------
|
|
236
|
+
# Sensitivity analysis
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def sensitivity_analysis(self, obj) -> np.ndarray:
|
|
240
|
+
"""
|
|
241
|
+
Compute gradient sensitivity of each latent dimension to predicted displacement.
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
grads : np.ndarray
|
|
246
|
+
shape (latent_dim,), mean absolute gradient per dimension.
|
|
247
|
+
"""
|
|
248
|
+
device = next(self.model.parameters()).device
|
|
249
|
+
obs = self.env._get_observation(obj)
|
|
250
|
+
obs_t = torch.tensor(
|
|
251
|
+
obs["visual"], dtype=torch.float32, device=device
|
|
252
|
+
).unsqueeze(0)
|
|
253
|
+
|
|
254
|
+
with torch.no_grad():
|
|
255
|
+
z = self.model.encode(obs_t).clone().detach().requires_grad_(True)
|
|
256
|
+
|
|
257
|
+
action = torch.zeros(1, self.model.action_dim, device=device)
|
|
258
|
+
pred = self.model.decode(z, action)
|
|
259
|
+
loss = torch.norm(pred[:, :3] - obs_t[:, :3])
|
|
260
|
+
loss.backward()
|
|
261
|
+
|
|
262
|
+
return z.grad.abs().squeeze(0).cpu().numpy()
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
# DCS (Disentanglement Completeness Score)
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def compute_dcs(
|
|
269
|
+
self, objects: list, property_name: str = "mass"
|
|
270
|
+
) -> float:
|
|
271
|
+
"""
|
|
272
|
+
Compute Disentanglement Completeness Score (DCS).
|
|
273
|
+
|
|
274
|
+
DCS = 1 - (second_best_correlation / best_correlation)
|
|
275
|
+
|
|
276
|
+
Value closer to 1 means the property is more concentrated in a single latent dimension.
|
|
277
|
+
This is a lightweight custom proxy for disentanglement (not the literature-standard DCI).
|
|
278
|
+
"""
|
|
279
|
+
result = self.test_property(objects, property_name)
|
|
280
|
+
all_corrs = result["all_corrs"]
|
|
281
|
+
if len(all_corrs) < 2:
|
|
282
|
+
return 0.0
|
|
283
|
+
|
|
284
|
+
# Top two by |r|
|
|
285
|
+
best_abs = all_corrs[0][2]
|
|
286
|
+
second_abs = all_corrs[1][2]
|
|
287
|
+
|
|
288
|
+
if best_abs < 1e-8:
|
|
289
|
+
return 0.0
|
|
290
|
+
|
|
291
|
+
dcs = 1.0 - (second_abs / best_abs)
|
|
292
|
+
return float(max(0.0, dcs))
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
# Internal utilities
|
|
296
|
+
# ------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def _permutation_test(
|
|
299
|
+
self, z_col: np.ndarray, props: np.ndarray, n_perm: int = 5000
|
|
300
|
+
) -> float:
|
|
301
|
+
"""Permutation test for Pearson correlation significance."""
|
|
302
|
+
observed_r = abs(float(np.corrcoef(z_col, props)[0, 1]))
|
|
303
|
+
if np.isnan(observed_r):
|
|
304
|
+
return 1.0
|
|
305
|
+
|
|
306
|
+
rng = np.random.RandomState(42)
|
|
307
|
+
null = np.array(
|
|
308
|
+
[
|
|
309
|
+
abs(
|
|
310
|
+
float(np.corrcoef(z_col, rng.permutation(props))[0, 1])
|
|
311
|
+
)
|
|
312
|
+
for _ in range(n_perm)
|
|
313
|
+
]
|
|
314
|
+
)
|
|
315
|
+
return float(max((null >= observed_r).mean(), 1.0 / n_perm))
|