nous 0.0.1__tar.gz → 0.1.0__tar.gz

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.

Potentially problematic release.


This version of nous might be problematic. Click here for more details.

nous-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: nous
3
+ Version: 0.1.0
4
+ Summary: Nous: A Neuro-Symbolic Library for Interpretable and Causal Reasoning AI
5
+ Author-email: Islam Tlupov <tlupovislam@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Islam Tlupov
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/EmotionEngineer/nous
29
+ Project-URL: Repository, https://github.com/EmotionEngineer/nous
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: Intended Audience :: Science/Research
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
36
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
37
+ Classifier: Typing :: Typed
38
+ Requires-Python: >=3.7
39
+ Description-Content-Type: text/markdown
40
+ License-File: LICENSE
41
+ Requires-Dist: torch>=1.8.0
42
+ Requires-Dist: pandas>=1.3.0
43
+ Requires-Dist: scikit-learn>=1.0
44
+ Requires-Dist: matplotlib>=3.3.0
45
+ Requires-Dist: networkx>=2.6
46
+ Requires-Dist: seaborn>=0.11
47
+ Dynamic: license-file
48
+
49
+ # Nous: A Neuro-Symbolic Library for Interpretable AI
50
+
51
+ [![PyPI version](https://badge.fury.io/py/nous.svg)](https://badge.fury.io/py/nous)
52
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
53
+
54
+ **Nous** is a PyTorch-based library for building "white-box" machine learning models for both **regression** and **classification**. It enables models that don't just predict, but also **reason** and **explain** their decisions in human-understandable terms.
55
+
56
+ ## Key Features
57
+
58
+ - **Deeply Interpretable**: Generate a complete, step-by-step logical trace (`fact -> rule -> concept -> prediction`) for any decision.
59
+ - **Supports Regression & Classification**: A unified API for predicting both continuous values and class probabilities.
60
+ - **Causal by Design**: Natively supports counterfactual analysis ("What if?") to provide actionable recommendations for both regression and classification tasks.
61
+ - **High Performance**: Achieves accuracy competitive with traditional black-box models.
62
+ - **Scalable & Flexible**: Choose between a high-performance `beta` activation, a robust `sigmoid`, or a maximally transparent `exhaustive` fact layer.
63
+
64
+ ## Installation
65
+
66
+ ```bash
67
+ pip install nous
68
+ ```
69
+
70
+ ## Quickstart: A 5-Minute Example (Regression)
71
+
72
+ Let's predict a house price and understand the model's reasoning.
73
+
74
+ ```python
75
+ import torch
76
+ import pandas as pd
77
+ from sklearn.datasets import make_regression
78
+ from nous.models import NousNet
79
+ from nous.interpret import trace_decision_graph, explain_fact
80
+ from nous.causal import find_counterfactual
81
+
82
+ # 1. Prepare Data
83
+ X_raw, y = make_regression(n_samples=1000, n_features=5, n_informative=3, noise=20, random_state=42)
84
+ feature_names = ['area_sqft', 'num_bedrooms', 'dist_to_center', 'age_years', 'renovation_quality']
85
+ X = torch.tensor(X_raw, dtype=torch.float32)
86
+ y = torch.tensor(y, dtype=torch.float32).unsqueeze(1)
87
+
88
+ # 2. Define and Train a NousNet for Regression
89
+ model = NousNet(
90
+ input_dim=5,
91
+ output_dim=1, # Single output for regression
92
+ feature_names=feature_names,
93
+ fact_layer_type='beta'
94
+ )
95
+ # Training: Use a regression loss like nn.MSELoss
96
+ # loss_fn = torch.nn.MSELoss()
97
+ # ... (standard training loop omitted)
98
+ model.eval()
99
+
100
+ # 3. Analyze a specific house
101
+ x_sample = X[50]
102
+ predicted_price = model(x_sample).item()
103
+ print(f"Model's predicted price for house #50: ${predicted_price:,.2f}")
104
+
105
+ # 4. Get the Step-by-Step Reasoning
106
+ graph = trace_decision_graph(model, x_sample)
107
+ top_facts = sorted(graph['trace']['Atomic Facts'].items(), key=lambda i: i[1]['value'], reverse=True)
108
+ fact_to_analyze = top_facts[0][0]
109
+ print(f"\nTop activated fact influencing the price: '{fact_to_analyze}'")
110
+
111
+ # 5. Decode the Learned Fact
112
+ details_df = explain_fact(model, fact_name=fact_to_analyze)
113
+ print(f"\nDecoding '{fact_to_analyze}':")
114
+ display(details_df.head())
115
+
116
+ # 6. Get an Actionable Recommendation
117
+ # What's the smallest change to increase the predicted price to $150,000?
118
+ recommendation = find_counterfactual(
119
+ model,
120
+ x_sample,
121
+ target_output=150.0, # Target value for regression
122
+ task='regression'
123
+ )
124
+ print("\nRecommendation to increase value to $150k:")
125
+ for feature, old_val, new_val in recommendation['changes']:
126
+ print(f"- Change '{feature}' from {old_val:.2f} to {new_val:.2f}")
127
+
128
+ ```
129
+
130
+ ## Choosing a `fact_layer_type`
131
+
132
+ - `'beta'` (**Default, Recommended**): Best performance and flexibility.
133
+ - `'sigmoid'`: A robust and reliable alternative.
134
+ - `'exhaustive'`: Maximum transparency. Best for low-dimensional problems (<15 features).
135
+
136
+ ## License
137
+
138
+ This project is licensed under the MIT License.
nous-0.1.0/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # Nous: A Neuro-Symbolic Library for Interpretable AI
2
+
3
+ [![PyPI version](https://badge.fury.io/py/nous.svg)](https://badge.fury.io/py/nous)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ **Nous** is a PyTorch-based library for building "white-box" machine learning models for both **regression** and **classification**. It enables models that don't just predict, but also **reason** and **explain** their decisions in human-understandable terms.
7
+
8
+ ## Key Features
9
+
10
+ - **Deeply Interpretable**: Generate a complete, step-by-step logical trace (`fact -> rule -> concept -> prediction`) for any decision.
11
+ - **Supports Regression & Classification**: A unified API for predicting both continuous values and class probabilities.
12
+ - **Causal by Design**: Natively supports counterfactual analysis ("What if?") to provide actionable recommendations for both regression and classification tasks.
13
+ - **High Performance**: Achieves accuracy competitive with traditional black-box models.
14
+ - **Scalable & Flexible**: Choose between a high-performance `beta` activation, a robust `sigmoid`, or a maximally transparent `exhaustive` fact layer.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install nous
20
+ ```
21
+
22
+ ## Quickstart: A 5-Minute Example (Regression)
23
+
24
+ Let's predict a house price and understand the model's reasoning.
25
+
26
+ ```python
27
+ import torch
28
+ import pandas as pd
29
+ from sklearn.datasets import make_regression
30
+ from nous.models import NousNet
31
+ from nous.interpret import trace_decision_graph, explain_fact
32
+ from nous.causal import find_counterfactual
33
+
34
+ # 1. Prepare Data
35
+ X_raw, y = make_regression(n_samples=1000, n_features=5, n_informative=3, noise=20, random_state=42)
36
+ feature_names = ['area_sqft', 'num_bedrooms', 'dist_to_center', 'age_years', 'renovation_quality']
37
+ X = torch.tensor(X_raw, dtype=torch.float32)
38
+ y = torch.tensor(y, dtype=torch.float32).unsqueeze(1)
39
+
40
+ # 2. Define and Train a NousNet for Regression
41
+ model = NousNet(
42
+ input_dim=5,
43
+ output_dim=1, # Single output for regression
44
+ feature_names=feature_names,
45
+ fact_layer_type='beta'
46
+ )
47
+ # Training: Use a regression loss like nn.MSELoss
48
+ # loss_fn = torch.nn.MSELoss()
49
+ # ... (standard training loop omitted)
50
+ model.eval()
51
+
52
+ # 3. Analyze a specific house
53
+ x_sample = X[50]
54
+ predicted_price = model(x_sample).item()
55
+ print(f"Model's predicted price for house #50: ${predicted_price:,.2f}")
56
+
57
+ # 4. Get the Step-by-Step Reasoning
58
+ graph = trace_decision_graph(model, x_sample)
59
+ top_facts = sorted(graph['trace']['Atomic Facts'].items(), key=lambda i: i[1]['value'], reverse=True)
60
+ fact_to_analyze = top_facts[0][0]
61
+ print(f"\nTop activated fact influencing the price: '{fact_to_analyze}'")
62
+
63
+ # 5. Decode the Learned Fact
64
+ details_df = explain_fact(model, fact_name=fact_to_analyze)
65
+ print(f"\nDecoding '{fact_to_analyze}':")
66
+ display(details_df.head())
67
+
68
+ # 6. Get an Actionable Recommendation
69
+ # What's the smallest change to increase the predicted price to $150,000?
70
+ recommendation = find_counterfactual(
71
+ model,
72
+ x_sample,
73
+ target_output=150.0, # Target value for regression
74
+ task='regression'
75
+ )
76
+ print("\nRecommendation to increase value to $150k:")
77
+ for feature, old_val, new_val in recommendation['changes']:
78
+ print(f"- Change '{feature}' from {old_val:.2f} to {new_val:.2f}")
79
+
80
+ ```
81
+
82
+ ## Choosing a `fact_layer_type`
83
+
84
+ - `'beta'` (**Default, Recommended**): Best performance and flexibility.
85
+ - `'sigmoid'`: A robust and reliable alternative.
86
+ - `'exhaustive'`: Maximum transparency. Best for low-dimensional problems (<15 features).
87
+
88
+ ## License
89
+
90
+ This project is licensed under the MIT License.
@@ -0,0 +1,26 @@
1
+ # nous/__init__.py
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .models import NousNet
6
+ from .interpret import (
7
+ trace_decision_graph,
8
+ explain_fact,
9
+ plot_logic_graph,
10
+ plot_fact_activation_function,
11
+ plot_final_layer_contributions,
12
+ )
13
+ from .causal import find_counterfactual
14
+
15
+ __all__ = [
16
+ # Main Model
17
+ "NousNet",
18
+ # Interpretation Suite
19
+ "trace_decision_graph",
20
+ "explain_fact",
21
+ "plot_logic_graph",
22
+ "plot_fact_activation_function",
23
+ "plot_final_layer_contributions",
24
+ # Causal Analysis
25
+ "find_counterfactual",
26
+ ]
@@ -0,0 +1,63 @@
1
+ # nous/causal.py
2
+ import torch
3
+ import torch.nn as nn
4
+ from typing import Literal
5
+ from .models import NousNet
6
+
7
+ def find_counterfactual(
8
+ model: NousNet,
9
+ x_sample: torch.Tensor,
10
+ target_output: float,
11
+ task: Literal['regression', 'classification'] = 'classification',
12
+ lr: float = 0.01,
13
+ steps: int = 200,
14
+ l1_lambda: float = 0.5
15
+ ) -> dict:
16
+ """
17
+ Finds a minimal change to the input to achieve a target output.
18
+
19
+ Args:
20
+ model (NousNet): The trained model.
21
+ x_sample (torch.Tensor): The original input tensor.
22
+ target_output (float): For classification, the desired probability (e.g., 0.8).
23
+ For regression, the desired absolute value (e.g., 150.0).
24
+ task (Literal['regression', 'classification']): The type of task.
25
+ lr, steps, l1_lambda: Optimization parameters.
26
+
27
+ Returns:
28
+ dict: A dictionary containing the counterfactual sample and a list of changes.
29
+ """
30
+ if task == 'classification':
31
+ if not (0 < target_output < 1):
32
+ raise ValueError("Target for classification must be a probability between 0 and 1.")
33
+ if model.output_head.out_features > 1:
34
+ raise NotImplementedError("Counterfactual analysis for multi-class models is not yet supported.")
35
+ # Calculate target in logit space for numerical stability
36
+ target = torch.log(torch.tensor(target_output) / (1 - torch.tensor(target_output)))
37
+ else: # regression
38
+ target = torch.tensor(target_output, dtype=torch.float32)
39
+
40
+ x_sample = x_sample.clone().detach()
41
+ delta = torch.zeros_like(x_sample, requires_grad=True)
42
+ optimizer = torch.optim.Adam([delta], lr=lr)
43
+
44
+ for _ in range(steps):
45
+ optimizer.zero_grad()
46
+ x_perturbed = x_sample + delta
47
+ prediction = model(x_perturbed.unsqueeze(0)).squeeze()
48
+
49
+ target_loss = (prediction - target)**2
50
+ l1_loss = torch.norm(delta, p=1)
51
+ total_loss = target_loss + l1_lambda * l1_loss
52
+
53
+ # This will now only be called for scalar losses, as multi-class is caught above
54
+ total_loss.backward()
55
+ optimizer.step()
56
+
57
+ final_x = x_sample + delta.detach()
58
+ changes = []
59
+ for i, name in enumerate(model.feature_names):
60
+ if not torch.isclose(x_sample[i], final_x[i], atol=1e-3):
61
+ changes.append((name, x_sample[i].item(), final_x[i].item()))
62
+
63
+ return {"counterfactual_x": final_x, "changes": changes}
@@ -0,0 +1,111 @@
1
+ # nous/interpret.py
2
+ import torch
3
+ import pandas as pd
4
+ import matplotlib.pyplot as plt
5
+ import seaborn as sns
6
+ import networkx as nx
7
+ from .models import NousNet
8
+ from .layers import LearnedAtomicFactLayer, BetaFactLayer
9
+
10
+ def trace_decision_graph(model: NousNet, x_sample: torch.Tensor) -> dict:
11
+ """Traces the full reasoning path for a single sample."""
12
+ if x_sample.dim() == 1: x_sample = x_sample.unsqueeze(0)
13
+ model.eval()
14
+ graph_data = {"trace": {}}
15
+ with torch.no_grad():
16
+ facts = model.atomic_fact_layer(x_sample).squeeze(0)
17
+ graph_data['trace']['Atomic Facts'] = {name: {"value": facts[i].item()} for i, name in enumerate(model.atomic_fact_layer.fact_names)}
18
+ h = facts.unsqueeze(0)
19
+ for i, block in enumerate(model.nous_blocks):
20
+ h, concepts, rule_activations = block(h)
21
+ concepts, rule_activations = concepts.squeeze(0), rule_activations.squeeze(0)
22
+ graph_data['trace'][f'Rules L{i}'] = {name: {"value": rule_activations[j].item()} for j, name in enumerate(block.rule_layer.rule_names)}
23
+ graph_data['trace'][f'Concepts L{i}'] = {name: {"value": concepts[j].item()} for j, name in enumerate(block.concept_names)}
24
+ return graph_data
25
+
26
+ def explain_fact(model: NousNet, fact_name: str) -> pd.DataFrame:
27
+ """Provides a detailed breakdown of a single learned fact, showing feature weights."""
28
+ fact_layer = model.atomic_fact_layer
29
+ if not isinstance(fact_layer, LearnedAtomicFactLayer):
30
+ raise TypeError("explain_fact is only applicable to 'beta' or 'sigmoid' fact layers.")
31
+ try:
32
+ fact_index = fact_layer.fact_names.index(fact_name)
33
+ except ValueError:
34
+ raise ValueError(f"Fact '{fact_name}' not found.")
35
+
36
+ with torch.no_grad():
37
+ w_left, w_right = fact_layer.projection_left.weight[fact_index], fact_layer.projection_right.weight[fact_index]
38
+ threshold = fact_layer.thresholds[fact_index]
39
+ df = pd.DataFrame({
40
+ "feature": model.feature_names,
41
+ "left_weight": w_left.cpu().detach().numpy(),
42
+ "right_weight": w_right.cpu().detach().numpy(),
43
+ })
44
+ df['net_effect'] = df['left_weight'] - df['right_weight']
45
+ print(f"Explanation for fact '{fact_name}':")
46
+ print(f"Fact is TRUE when: (Sum(left_weight * feat) - Sum(right_weight * feat)) > {threshold.item():.3f}")
47
+ return df.sort_values(by='net_effect', key=abs, ascending=False)
48
+
49
+ def plot_fact_activation_function(model: NousNet, fact_name: str, x_range=(-3, 3), n_points=200):
50
+ """Visualizes the learned activation function for a single learned fact."""
51
+ fact_layer = model.atomic_fact_layer
52
+ if not isinstance(fact_layer, LearnedAtomicFactLayer):
53
+ raise TypeError("This function is only for 'beta' or 'sigmoid' fact layers.")
54
+ try:
55
+ fact_index = fact_layer.fact_names.index(fact_name)
56
+ except ValueError:
57
+ raise ValueError(f"Fact '{fact_name}' not found.")
58
+
59
+ diff_range = torch.linspace(x_range[0], x_range[1], n_points)
60
+
61
+ with torch.no_grad():
62
+ if isinstance(fact_layer, BetaFactLayer):
63
+ k = torch.nn.functional.softplus(fact_layer.k_raw[fact_index]) + 1e-4
64
+ nu = torch.nn.functional.softplus(fact_layer.nu_raw[fact_index]) + 1e-4
65
+ activations = (1 + torch.exp(-k * diff_range))**(-nu)
66
+ label = f'Learned Beta-like (k={k:.2f}, ν={nu:.2f})'
67
+ else: # SigmoidFactLayer
68
+ steepness = torch.nn.functional.softplus(fact_layer.steepness[fact_index]) + 1e-4
69
+ activations = torch.sigmoid(steepness * diff_range)
70
+ label = f'Learned Sigmoid (steepness={steepness:.2f})'
71
+
72
+ plt.figure(figsize=(8, 5))
73
+ plt.plot(diff_range.numpy(), activations.numpy(), label=label, linewidth=2.5)
74
+ plt.plot(diff_range.numpy(), torch.sigmoid(diff_range).numpy(), label='Standard Sigmoid', linestyle='--', color='gray')
75
+ plt.title(f"Activation Function for Fact:\n'{fact_name}'")
76
+ plt.xlabel("Difference Value (Left Projection - Right Projection - Threshold)")
77
+ plt.ylabel("Fact Activation (Truth Value)")
78
+ plt.legend(); plt.grid(True, linestyle=':'); plt.ylim(-0.05, 1.05); plt.show()
79
+
80
+ def plot_final_layer_contributions(model: NousNet, x_sample: torch.Tensor):
81
+ """Calculates and plots which high-level concepts most influenced the final prediction."""
82
+ if x_sample.dim() == 1: x_sample = x_sample.unsqueeze(0)
83
+ model.eval()
84
+ with torch.no_grad():
85
+ h = model.atomic_fact_layer(x_sample)
86
+ for block in model.nous_blocks: h, _, _ = block(h)
87
+ final_activations = h.squeeze(0)
88
+
89
+ output_dim = model.output_head.out_features
90
+ weights = model.output_head.weight.squeeze(0)
91
+ title = "Top Final Layer Concept Contributions"
92
+
93
+ if output_dim > 1:
94
+ predicted_class = model.output_head(h).argmax().item()
95
+ weights = model.output_head.weight[predicted_class]
96
+ title += f" for Predicted Class {predicted_class}"
97
+
98
+ contributions = final_activations * weights
99
+ concept_names = model.nous_blocks[-1].concept_names
100
+
101
+ df = pd.DataFrame({'concept': concept_names, 'contribution': contributions.cpu().detach().numpy()})
102
+ df = df.sort_values('contribution', key=abs, ascending=False).head(15)
103
+
104
+ plt.figure(figsize=(10, 6));
105
+ colors = ['#5fba7d' if c > 0 else '#d65f5f' for c in df['contribution']]
106
+ sns.barplot(x='contribution', y='concept', data=df, palette=colors, dodge=False)
107
+ plt.title(title); plt.xlabel("Contribution (Activation * Weight)"); plt.ylabel("Final Layer Concept"); plt.show()
108
+
109
+ def plot_logic_graph(*args, **kwargs):
110
+ print("Graph visualization is planned for a future release.")
111
+ pass
@@ -0,0 +1,117 @@
1
+ # nous/layers.py
2
+ import torch
3
+ import torch.nn as nn
4
+ from typing import List, Tuple
5
+
6
+ # --- Fact Layers ---
7
+
8
+ class ExhaustiveAtomicFactLayer(nn.Module):
9
+ """Generates atomic facts by exhaustively comparing all pairs of features."""
10
+ def __init__(self, input_dim: int, feature_names: List[str]):
11
+ super().__init__()
12
+ if input_dim > 20:
13
+ num_facts = input_dim * (input_dim - 1) // 2
14
+ print(f"Warning: ExhaustiveAtomicFactLayer with {input_dim} features will create {num_facts} facts. This may be slow and memory-intensive.")
15
+ self.indices = torch.combinations(torch.arange(input_dim), r=2)
16
+ self.thresholds = nn.Parameter(torch.randn(self.indices.shape[0]) * 0.1)
17
+ self.steepness = nn.Parameter(torch.ones(self.indices.shape[0]) * 5.0)
18
+ self.fact_names = [f"({feature_names[i]} > {feature_names[j]})" for i, j in self.indices.numpy()]
19
+
20
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
21
+ steepness = torch.nn.functional.softplus(self.steepness) + 1e-4
22
+ diffs = x[:, self.indices[:, 0]] - x[:, self.indices[:, 1]]
23
+ return torch.sigmoid(steepness * (diffs - self.thresholds))
24
+
25
+ @property
26
+ def output_dim(self) -> int: return len(self.fact_names)
27
+
28
+ class LearnedAtomicFactLayer(nn.Module):
29
+ """Base class for learnable fact layers (Sigmoid and Beta)."""
30
+ def __init__(self, input_dim: int, num_facts: int, feature_names: List[str]):
31
+ super().__init__()
32
+ self.input_dim = input_dim
33
+ self.num_facts = num_facts
34
+ self._feature_names = feature_names
35
+ self.projection_left = nn.Linear(input_dim, num_facts, bias=False)
36
+ self.projection_right = nn.Linear(input_dim, num_facts, bias=False)
37
+ self.thresholds = nn.Parameter(torch.randn(num_facts) * 0.1)
38
+
39
+ @property
40
+ def output_dim(self) -> int: return self.num_facts
41
+
42
+ def get_base_diffs(self, x: torch.Tensor) -> torch.Tensor:
43
+ """Calculates the core difference term for all activation functions."""
44
+ return (self.projection_left(x) - self.projection_right(x)) - self.thresholds
45
+
46
+ def fact_names(self, prefix: str) -> List[str]:
47
+ """Generates human-readable and unique names for facts."""
48
+ names = []
49
+ with torch.no_grad():
50
+ w_left, w_right = self.projection_left.weight, self.projection_right.weight
51
+ for i in range(self.output_dim):
52
+ left_name = self._feature_names[w_left[i].abs().argmax().item()]
53
+ right_name = self._feature_names[w_right[i].abs().argmax().item()]
54
+ base_name = f"({left_name} vs {right_name})" if left_name != right_name else f"Thresh({left_name})"
55
+ names.append(f"{prefix}-{i}{base_name}")
56
+ return names
57
+
58
+ # --- Specialized Learnable Fact Layers ---
59
+
60
+ class SigmoidFactLayer(LearnedAtomicFactLayer):
61
+ """A learnable fact layer using the standard sigmoid activation."""
62
+ def __init__(self, input_dim: int, num_facts: int, feature_names: List[str]):
63
+ super().__init__(input_dim, num_facts, feature_names)
64
+ self.steepness = nn.Parameter(torch.ones(num_facts) * 5.0)
65
+
66
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
67
+ diffs = self.get_base_diffs(x)
68
+ steepness = torch.nn.functional.softplus(self.steepness) + 1e-4
69
+ return torch.sigmoid(steepness * diffs)
70
+
71
+ @property
72
+ def fact_names(self) -> List[str]: return super().fact_names(prefix="Sigmoid")
73
+
74
+ class BetaFactLayer(LearnedAtomicFactLayer):
75
+ """A learnable fact layer using a flexible, generalized logistic function."""
76
+ def __init__(self, input_dim: int, num_facts: int, feature_names: List[str]):
77
+ super().__init__(input_dim, num_facts, feature_names)
78
+ self.k_raw = nn.Parameter(torch.ones(num_facts) * 0.5)
79
+ self.nu_raw = nn.Parameter(torch.zeros(num_facts))
80
+
81
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
82
+ diffs = self.get_base_diffs(x)
83
+ k = torch.nn.functional.softplus(self.k_raw) + 1e-4
84
+ nu = torch.nn.functional.softplus(self.nu_raw) + 1e-4
85
+ return (1 + torch.exp(-k * diffs)) ** (-nu)
86
+
87
+ @property
88
+ def fact_names(self) -> List[str]: return super().fact_names(prefix="Beta")
89
+
90
+ # --- Rule/Concept Layers ---
91
+
92
+ class LogicalRuleLayer(nn.Module):
93
+ """Forms logical rules (AND) from facts and outputs higher-level concepts."""
94
+ def __init__(self, input_dim: int, num_rules: int, input_fact_names: List[str]):
95
+ super().__init__()
96
+ torch.manual_seed(input_dim + num_rules)
97
+
98
+ if input_dim > 0 and num_rules > 0:
99
+ self.register_buffer('rule_indices', torch.randint(0, input_dim, size=(num_rules, 2)))
100
+ self.rule_names = [f"({input_fact_names[i]} AND {input_fact_names[j]})" for i, j in self.rule_indices]
101
+ else:
102
+ self.register_buffer('rule_indices', torch.empty(0, 2, dtype=torch.long))
103
+ self.rule_names = []
104
+
105
+ self.concept_generator = nn.Linear(num_rules, num_rules)
106
+ self.concept_names = [f"Concept-{i}" for i in range(num_rules)]
107
+
108
+ def forward(self, facts: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
109
+ if self.rule_indices.shape[0] == 0:
110
+ return torch.zeros(facts.shape[0], 0).to(facts.device), torch.zeros(facts.shape[0], 0).to(facts.device)
111
+ fact1, fact2 = facts[:, self.rule_indices[:, 0]], facts[:, self.rule_indices[:, 1]]
112
+ rule_activations = fact1 * fact2
113
+ concepts = torch.sigmoid(self.concept_generator(rule_activations))
114
+ return concepts, rule_activations
115
+
116
+ @property
117
+ def output_dim(self) -> int: return len(self.concept_names)
@@ -0,0 +1,65 @@
1
+ # nous/models.py
2
+ import torch
3
+ import torch.nn as nn
4
+ from typing import List, Literal, Tuple
5
+ from .layers import ExhaustiveAtomicFactLayer, SigmoidFactLayer, BetaFactLayer, LogicalRuleLayer
6
+
7
+ class NousBlock(nn.Module):
8
+ """A single reasoning block in the Nous network with a residual connection."""
9
+ def __init__(self, input_dim: int, num_rules: int, input_fact_names: List[str]):
10
+ super().__init__()
11
+ self.rule_layer = LogicalRuleLayer(input_dim, num_rules, input_fact_names)
12
+ self.projection = nn.Linear(input_dim, num_rules) if input_dim != num_rules else nn.Identity()
13
+ self.norm = nn.LayerNorm(num_rules)
14
+
15
+ def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
16
+ concepts, rule_activations = self.rule_layer(x)
17
+ output = self.norm(self.projection(x) + concepts)
18
+ return output, concepts, rule_activations
19
+
20
+ @property
21
+ def concept_names(self) -> List[str]:
22
+ return self.rule_layer.concept_names
23
+
24
+ class NousNet(nn.Module):
25
+ """
26
+ The complete Nous neuro-symbolic network for regression and classification.
27
+ """
28
+ def __init__(self,
29
+ input_dim: int,
30
+ output_dim: int,
31
+ feature_names: List[str],
32
+ fact_layer_type: Literal['beta', 'sigmoid', 'exhaustive'] = 'beta',
33
+ num_facts: int = 30,
34
+ num_rules_per_layer: List[int] = [10, 5]):
35
+ super().__init__()
36
+ self.feature_names = feature_names
37
+
38
+ if fact_layer_type == 'beta':
39
+ self.atomic_fact_layer = BetaFactLayer(input_dim, num_facts, feature_names)
40
+ elif fact_layer_type == 'sigmoid':
41
+ self.atomic_fact_layer = SigmoidFactLayer(input_dim, num_facts, feature_names)
42
+ elif fact_layer_type == 'exhaustive':
43
+ self.atomic_fact_layer = ExhaustiveAtomicFactLayer(input_dim, feature_names)
44
+ else:
45
+ raise ValueError("fact_layer_type must be 'beta', 'sigmoid', or 'exhaustive'")
46
+
47
+ self.nous_blocks = nn.ModuleList()
48
+ current_dim = self.atomic_fact_layer.output_dim
49
+
50
+ for i, num_rules in enumerate(num_rules_per_layer):
51
+ input_names = self.atomic_fact_layer.fact_names if i == 0 else self.nous_blocks[i-1].concept_names
52
+ block = NousBlock(current_dim, num_rules, input_names)
53
+ self.nous_blocks.append(block)
54
+ current_dim = num_rules
55
+
56
+ self.output_head = nn.Linear(current_dim, output_dim)
57
+
58
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
59
+ """
60
+ Forward pass. Returns logits for classification or direct values for regression.
61
+ """
62
+ h = self.atomic_fact_layer(x)
63
+ for block in self.nous_blocks:
64
+ h, _, _ = block(h)
65
+ return self.output_head(h)
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: nous
3
+ Version: 0.1.0
4
+ Summary: Nous: A Neuro-Symbolic Library for Interpretable and Causal Reasoning AI
5
+ Author-email: Islam Tlupov <tlupovislam@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Islam Tlupov
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/EmotionEngineer/nous
29
+ Project-URL: Repository, https://github.com/EmotionEngineer/nous
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: Intended Audience :: Science/Research
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
36
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
37
+ Classifier: Typing :: Typed
38
+ Requires-Python: >=3.7
39
+ Description-Content-Type: text/markdown
40
+ License-File: LICENSE
41
+ Requires-Dist: torch>=1.8.0
42
+ Requires-Dist: pandas>=1.3.0
43
+ Requires-Dist: scikit-learn>=1.0
44
+ Requires-Dist: matplotlib>=3.3.0
45
+ Requires-Dist: networkx>=2.6
46
+ Requires-Dist: seaborn>=0.11
47
+ Dynamic: license-file
48
+
49
+ # Nous: A Neuro-Symbolic Library for Interpretable AI
50
+
51
+ [![PyPI version](https://badge.fury.io/py/nous.svg)](https://badge.fury.io/py/nous)
52
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
53
+
54
+ **Nous** is a PyTorch-based library for building "white-box" machine learning models for both **regression** and **classification**. It enables models that don't just predict, but also **reason** and **explain** their decisions in human-understandable terms.
55
+
56
+ ## Key Features
57
+
58
+ - **Deeply Interpretable**: Generate a complete, step-by-step logical trace (`fact -> rule -> concept -> prediction`) for any decision.
59
+ - **Supports Regression & Classification**: A unified API for predicting both continuous values and class probabilities.
60
+ - **Causal by Design**: Natively supports counterfactual analysis ("What if?") to provide actionable recommendations for both regression and classification tasks.
61
+ - **High Performance**: Achieves accuracy competitive with traditional black-box models.
62
+ - **Scalable & Flexible**: Choose between a high-performance `beta` activation, a robust `sigmoid`, or a maximally transparent `exhaustive` fact layer.
63
+
64
+ ## Installation
65
+
66
+ ```bash
67
+ pip install nous
68
+ ```
69
+
70
+ ## Quickstart: A 5-Minute Example (Regression)
71
+
72
+ Let's predict a house price and understand the model's reasoning.
73
+
74
+ ```python
75
+ import torch
76
+ import pandas as pd
77
+ from sklearn.datasets import make_regression
78
+ from nous.models import NousNet
79
+ from nous.interpret import trace_decision_graph, explain_fact
80
+ from nous.causal import find_counterfactual
81
+
82
+ # 1. Prepare Data
83
+ X_raw, y = make_regression(n_samples=1000, n_features=5, n_informative=3, noise=20, random_state=42)
84
+ feature_names = ['area_sqft', 'num_bedrooms', 'dist_to_center', 'age_years', 'renovation_quality']
85
+ X = torch.tensor(X_raw, dtype=torch.float32)
86
+ y = torch.tensor(y, dtype=torch.float32).unsqueeze(1)
87
+
88
+ # 2. Define and Train a NousNet for Regression
89
+ model = NousNet(
90
+ input_dim=5,
91
+ output_dim=1, # Single output for regression
92
+ feature_names=feature_names,
93
+ fact_layer_type='beta'
94
+ )
95
+ # Training: Use a regression loss like nn.MSELoss
96
+ # loss_fn = torch.nn.MSELoss()
97
+ # ... (standard training loop omitted)
98
+ model.eval()
99
+
100
+ # 3. Analyze a specific house
101
+ x_sample = X[50]
102
+ predicted_price = model(x_sample).item()
103
+ print(f"Model's predicted price for house #50: ${predicted_price:,.2f}")
104
+
105
+ # 4. Get the Step-by-Step Reasoning
106
+ graph = trace_decision_graph(model, x_sample)
107
+ top_facts = sorted(graph['trace']['Atomic Facts'].items(), key=lambda i: i[1]['value'], reverse=True)
108
+ fact_to_analyze = top_facts[0][0]
109
+ print(f"\nTop activated fact influencing the price: '{fact_to_analyze}'")
110
+
111
+ # 5. Decode the Learned Fact
112
+ details_df = explain_fact(model, fact_name=fact_to_analyze)
113
+ print(f"\nDecoding '{fact_to_analyze}':")
114
+ display(details_df.head())
115
+
116
+ # 6. Get an Actionable Recommendation
117
+ # What's the smallest change to increase the predicted price to $150,000?
118
+ recommendation = find_counterfactual(
119
+ model,
120
+ x_sample,
121
+ target_output=150.0, # Target value for regression
122
+ task='regression'
123
+ )
124
+ print("\nRecommendation to increase value to $150k:")
125
+ for feature, old_val, new_val in recommendation['changes']:
126
+ print(f"- Change '{feature}' from {old_val:.2f} to {new_val:.2f}")
127
+
128
+ ```
129
+
130
+ ## Choosing a `fact_layer_type`
131
+
132
+ - `'beta'` (**Default, Recommended**): Best performance and flexibility.
133
+ - `'sigmoid'`: A robust and reliable alternative.
134
+ - `'exhaustive'`: Maximum transparency. Best for low-dimensional problems (<15 features).
135
+
136
+ ## License
137
+
138
+ This project is licensed under the MIT License.
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ nous/__init__.py
5
+ nous/causal.py
6
+ nous/interpret.py
7
+ nous/layers.py
8
+ nous/models.py
9
+ nous.egg-info/PKG-INFO
10
+ nous.egg-info/SOURCES.txt
11
+ nous.egg-info/dependency_links.txt
12
+ nous.egg-info/requires.txt
13
+ nous.egg-info/top_level.txt
14
+ tests/test_interpret_causal.py
15
+ tests/test_layers.py
16
+ tests/test_models.py
@@ -0,0 +1,6 @@
1
+ torch>=1.8.0
2
+ pandas>=1.3.0
3
+ scikit-learn>=1.0
4
+ matplotlib>=3.3.0
5
+ networkx>=2.6
6
+ seaborn>=0.11
@@ -0,0 +1,37 @@
1
+ # pyproject.toml
2
+ [build-system]
3
+ requires = ["setuptools>=61.0", "wheel"]
4
+ build-backend = "setuptools.build_meta"
5
+
6
+ [project]
7
+ name = "nous"
8
+ version = "0.1.0"
9
+ description = "Nous: A Neuro-Symbolic Library for Interpretable and Causal Reasoning AI"
10
+ readme = "README.md"
11
+ requires-python = ">=3.7"
12
+ license = { file = "LICENSE" }
13
+ authors = [
14
+ { name = "Islam Tlupov", email = "tlupovislam@gmail.com" },
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Science/Research",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Typing :: Typed"
25
+ ]
26
+ dependencies = [
27
+ "torch>=1.8.0",
28
+ "pandas>=1.3.0",
29
+ "scikit-learn>=1.0",
30
+ "matplotlib>=3.3.0",
31
+ "networkx>=2.6",
32
+ "seaborn>=0.11"
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/EmotionEngineer/nous"
37
+ Repository = "https://github.com/EmotionEngineer/nous"
@@ -0,0 +1,77 @@
1
+ # tests/test_interpret_causal.py
2
+ import torch
3
+ import pytest
4
+ import pandas as pd
5
+ import matplotlib
6
+ matplotlib.use('Agg')
7
+ import matplotlib.pyplot as plt
8
+
9
+ from nous.models import NousNet
10
+ from nous.interpret import trace_decision_graph, explain_fact, plot_fact_activation_function, plot_final_layer_contributions, plot_logic_graph
11
+ from nous.causal import find_counterfactual
12
+
13
+ INPUT_DIM = 5
14
+ FEATURE_NAMES = [f'feat_{i}' for i in range(INPUT_DIM)]
15
+
16
+ @pytest.fixture
17
+ def beta_model():
18
+ return NousNet(input_dim=INPUT_DIM, output_dim=1, feature_names=FEATURE_NAMES,
19
+ fact_layer_type='beta', num_facts=10, num_rules_per_layer=[5])
20
+
21
+ @pytest.fixture
22
+ def exhaustive_model():
23
+ return NousNet(input_dim=INPUT_DIM, output_dim=1, feature_names=FEATURE_NAMES, fact_layer_type='exhaustive')
24
+
25
+ @pytest.fixture
26
+ def multiclass_model():
27
+ return NousNet(input_dim=INPUT_DIM, output_dim=3, feature_names=FEATURE_NAMES, fact_layer_type='sigmoid')
28
+
29
+ @pytest.fixture
30
+ def x_sample(): return torch.randn(INPUT_DIM)
31
+
32
+ def test_trace_decision_graph(beta_model, x_sample):
33
+ graph = trace_decision_graph(beta_model, x_sample)
34
+ assert isinstance(graph, dict)
35
+ assert 'trace' in graph
36
+ assert len(graph['trace']['Atomic Facts']) == beta_model.atomic_fact_layer.output_dim
37
+ assert len(graph['trace']['Concepts L0']) == beta_model.nous_blocks[0].rule_layer.output_dim
38
+
39
+ def test_explain_fact(beta_model):
40
+ fact_name = beta_model.atomic_fact_layer.fact_names[0]
41
+ df = explain_fact(beta_model, fact_name=fact_name)
42
+ assert isinstance(df, pd.DataFrame)
43
+ assert 'net_effect' in df.columns
44
+
45
+ def test_explain_fact_errors(exhaustive_model, beta_model):
46
+ with pytest.raises(TypeError): explain_fact(exhaustive_model, fact_name="any")
47
+ with pytest.raises(ValueError): explain_fact(beta_model, fact_name="non_existent_fact")
48
+
49
+ def test_plot_fact_activation_function(beta_model):
50
+ fact_name = beta_model.atomic_fact_layer.fact_names[0]
51
+ plot_fact_activation_function(beta_model, fact_name=fact_name)
52
+ plt.close()
53
+
54
+ def test_plot_final_layer_contributions(beta_model, multiclass_model, x_sample):
55
+ plot_final_layer_contributions(beta_model, x_sample)
56
+ plt.close()
57
+ plot_final_layer_contributions(multiclass_model, x_sample)
58
+ plt.close()
59
+
60
+ def test_plot_logic_graph(beta_model, x_sample):
61
+ graph = trace_decision_graph(beta_model, x_sample)
62
+ plot_logic_graph(graph)
63
+
64
+ def test_find_counterfactual(beta_model, x_sample):
65
+ # Classification
66
+ result_class = find_counterfactual(beta_model, x_sample, target_output=0.8, task='classification')
67
+ assert 'counterfactual_x' in result_class
68
+
69
+ # Regression
70
+ result_reg = find_counterfactual(beta_model, x_sample, target_output=150.0, task='regression')
71
+ assert 'changes' in result_reg
72
+
73
+ def test_find_counterfactual_errors(multiclass_model, beta_model, x_sample):
74
+ with pytest.raises(NotImplementedError):
75
+ find_counterfactual(multiclass_model, x_sample, target_output=0.8)
76
+ with pytest.raises(ValueError):
77
+ find_counterfactual(beta_model, x_sample, target_output=1.1, task='classification')
@@ -0,0 +1,97 @@
1
+ # tests/test_layers.py
2
+
3
+ import torch
4
+ import pytest
5
+ from nous.layers import (
6
+ ExhaustiveAtomicFactLayer,
7
+ SigmoidFactLayer,
8
+ BetaFactLayer,
9
+ LogicalRuleLayer,
10
+ )
11
+
12
+ # --- Test Constants ---
13
+ BATCH_SIZE = 4
14
+ INPUT_DIM = 5
15
+ NUM_FACTS_LEARNED = 10
16
+ NUM_RULES = 8
17
+ FEATURE_NAMES = [f'feat_{i}' for i in range(INPUT_DIM)]
18
+
19
+ # --- Fixtures ---
20
+ @pytest.fixture
21
+ def sample_data():
22
+ return torch.randn(BATCH_SIZE, INPUT_DIM)
23
+
24
+ # --- Layer Tests ---
25
+
26
+ def test_exhaustive_atomic_fact_layer(sample_data):
27
+ layer = ExhaustiveAtomicFactLayer(input_dim=INPUT_DIM, feature_names=FEATURE_NAMES)
28
+
29
+ # Check output dimension: C(5, 2) = 10
30
+ expected_dim = INPUT_DIM * (INPUT_DIM - 1) // 2
31
+ assert layer.output_dim == expected_dim
32
+ assert len(layer.fact_names) == expected_dim
33
+
34
+ # Check forward pass
35
+ output = layer(sample_data)
36
+ assert output.shape == (BATCH_SIZE, expected_dim)
37
+ assert not torch.isnan(output).any()
38
+ assert (output >= 0).all() and (output <= 1).all()
39
+
40
+ # Check gradient flow
41
+ output.sum().backward()
42
+ assert layer.thresholds.grad is not None
43
+ assert layer.steepness.grad is not None
44
+
45
+ @pytest.mark.parametrize("layer_class", [SigmoidFactLayer, BetaFactLayer])
46
+ def test_learned_atomic_fact_layers(sample_data, layer_class):
47
+ layer = layer_class(input_dim=INPUT_DIM, num_facts=NUM_FACTS_LEARNED, feature_names=FEATURE_NAMES)
48
+
49
+ # Check output dimension
50
+ assert layer.output_dim == NUM_FACTS_LEARNED
51
+ assert len(layer.fact_names) == NUM_FACTS_LEARNED
52
+
53
+ # Check forward pass
54
+ output = layer(sample_data)
55
+ assert output.shape == (BATCH_SIZE, NUM_FACTS_LEARNED)
56
+ assert not torch.isnan(output).any()
57
+ assert (output >= 0).all() and (output <= 1).all()
58
+
59
+ # Check gradient flow
60
+ output.sum().backward()
61
+ assert layer.projection_left.weight.grad is not None
62
+ assert layer.projection_right.weight.grad is not None
63
+ assert layer.thresholds.grad is not None
64
+ # Check specific params
65
+ if isinstance(layer, SigmoidFactLayer):
66
+ assert layer.steepness.grad is not None
67
+ if isinstance(layer, BetaFactLayer):
68
+ assert layer.k_raw.grad is not None
69
+ assert layer.nu_raw.grad is not None
70
+
71
+ def test_logical_rule_layer():
72
+ input_facts = torch.rand(BATCH_SIZE, NUM_FACTS_LEARNED)
73
+ fact_names = [f'fact_{i}' for i in range(NUM_FACTS_LEARNED)]
74
+
75
+ layer = LogicalRuleLayer(input_dim=NUM_FACTS_LEARNED, num_rules=NUM_RULES, input_fact_names=fact_names)
76
+
77
+ # Check output dimension
78
+ assert layer.output_dim == NUM_RULES
79
+
80
+ # Check forward pass
81
+ concepts, rule_activations = layer(input_facts)
82
+ assert concepts.shape == (BATCH_SIZE, NUM_RULES)
83
+ assert rule_activations.shape == (BATCH_SIZE, NUM_RULES)
84
+ assert not torch.isnan(concepts).any()
85
+
86
+ # Check gradient flow
87
+ concepts.sum().backward()
88
+ assert layer.concept_generator.weight.grad is not None
89
+
90
+ def test_logical_rule_layer_empty_input():
91
+ """Test that the layer handles zero input facts gracefully."""
92
+ input_facts = torch.rand(BATCH_SIZE, 0)
93
+ layer = LogicalRuleLayer(input_dim=0, num_rules=0, input_fact_names=[])
94
+
95
+ concepts, rule_activations = layer(input_facts)
96
+ assert concepts.shape == (BATCH_SIZE, 0)
97
+ assert rule_activations.shape == (BATCH_SIZE, 0)
@@ -0,0 +1,93 @@
1
+ # tests/test_models.py
2
+
3
+ import torch
4
+ import pytest
5
+ from nous.models import NousNet, NousBlock
6
+
7
+ # --- Test Constants ---
8
+ BATCH_SIZE = 4
9
+ INPUT_DIM = 8
10
+ OUTPUT_DIM_BINARY = 1
11
+ OUTPUT_DIM_MULTI = 3
12
+ FEATURE_NAMES = [f'feat_{i}' for i in range(INPUT_DIM)]
13
+
14
+ # --- Fixtures ---
15
+ @pytest.fixture
16
+ def sample_data():
17
+ return torch.randn(BATCH_SIZE, INPUT_DIM)
18
+
19
+ @pytest.fixture
20
+ def nous_block_input():
21
+ return torch.rand(BATCH_SIZE, 10) # 10 input facts/concepts
22
+
23
+ # --- Model Tests ---
24
+
25
+ def test_nous_block(nous_block_input):
26
+ block = NousBlock(input_dim=10, num_rules=5, input_fact_names=[f'f_{i}' for i in range(10)])
27
+
28
+ # Check forward pass
29
+ output, concepts, rules = block(nous_block_input)
30
+ assert output.shape == (BATCH_SIZE, 5)
31
+ assert concepts.shape == (BATCH_SIZE, 5)
32
+ assert rules.shape == (BATCH_SIZE, 5)
33
+
34
+ # Check gradient flow
35
+ output.sum().backward()
36
+ assert all(p.grad is not None for p in block.parameters())
37
+
38
+ @pytest.mark.parametrize("fact_type", ['beta', 'sigmoid', 'exhaustive'])
39
+ def test_nous_net_binary_classification(sample_data, fact_type):
40
+ model = NousNet(
41
+ input_dim=INPUT_DIM,
42
+ output_dim=OUTPUT_DIM_BINARY,
43
+ feature_names=FEATURE_NAMES,
44
+ fact_layer_type=fact_type,
45
+ num_facts=15,
46
+ num_rules_per_layer=[10, 5]
47
+ )
48
+
49
+ # Check forward pass
50
+ output = model(sample_data)
51
+ assert output.shape == (BATCH_SIZE, OUTPUT_DIM_BINARY)
52
+ assert not torch.isnan(output).any()
53
+
54
+ # Check gradient flow
55
+ output.sum().backward()
56
+ assert all(p.grad is not None for p in model.parameters())
57
+
58
+ @pytest.mark.parametrize("fact_type", ['beta', 'sigmoid', 'exhaustive'])
59
+ def test_nous_net_multiclass_classification(sample_data, fact_type):
60
+ model = NousNet(
61
+ input_dim=INPUT_DIM,
62
+ output_dim=OUTPUT_DIM_MULTI,
63
+ feature_names=FEATURE_NAMES,
64
+ fact_layer_type=fact_type,
65
+ num_facts=15,
66
+ num_rules_per_layer=[10, 5]
67
+ )
68
+
69
+ # Check forward pass
70
+ output = model(sample_data)
71
+ assert output.shape == (BATCH_SIZE, OUTPUT_DIM_MULTI)
72
+
73
+ @pytest.mark.parametrize("fact_type", ['beta', 'sigmoid', 'exhaustive'])
74
+ def test_nous_net_regression(sample_data, fact_type):
75
+ model = NousNet(
76
+ input_dim=INPUT_DIM,
77
+ output_dim=1, # Regression output
78
+ feature_names=FEATURE_NAMES,
79
+ fact_layer_type=fact_type
80
+ )
81
+
82
+ # Check forward pass
83
+ output = model(sample_data)
84
+ assert output.shape == (BATCH_SIZE, 1)
85
+
86
+ def test_nous_net_invalid_fact_layer():
87
+ with pytest.raises(ValueError):
88
+ NousNet(
89
+ input_dim=INPUT_DIM,
90
+ output_dim=1,
91
+ feature_names=FEATURE_NAMES,
92
+ fact_layer_type='invalid_type'
93
+ )
nous-0.0.1/PKG-INFO DELETED
@@ -1,43 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: nous
3
- Version: 0.0.1
4
- Summary: Placeholder for the Nous neuro-symbolic AI library. Full implementation coming soon.
5
- Author-email: Islam Tlupov <tlupovislam@gmail.com>
6
- License: MIT License
7
-
8
- Copyright (c) 2025 Islam Tlupov
9
-
10
- Permission is hereby granted, free of charge, to any person obtaining a copy
11
- of this software and associated documentation files (the "Software"), to deal
12
- in the Software without restriction, including without limitation the rights
13
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
- copies of the Software, and to permit persons to whom the Software is
15
- furnished to do so, subject to the following conditions:
16
-
17
- The above copyright notice and this permission notice shall be included in all
18
- copies or substantial portions of the Software.
19
-
20
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
- SOFTWARE.
27
-
28
- Project-URL: Homepage, https://github.com/EmotionEngineer/nous
29
- Classifier: Development Status :: 1 - Planning
30
- Classifier: Intended Audience :: Developers
31
- Classifier: License :: OSI Approved :: MIT License
32
- Classifier: Programming Language :: Python :: 3
33
- Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
34
- Requires-Python: >=3.8
35
- Description-Content-Type: text/markdown
36
- License-File: LICENSE
37
- Dynamic: license-file
38
-
39
- # nous
40
-
41
- **This package name is reserved for the Nous neuro-symbolic AI library.**
42
-
43
- A full implementation is in active development and will be published here soon.
nous-0.0.1/README.md DELETED
@@ -1,5 +0,0 @@
1
- # nous
2
-
3
- **This package name is reserved for the Nous neuro-symbolic AI library.**
4
-
5
- A full implementation is in active development and will be published here soon.
@@ -1,2 +0,0 @@
1
- # nous/__init__.py
2
- __version__ = "0.0.1"
@@ -1,43 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: nous
3
- Version: 0.0.1
4
- Summary: Placeholder for the Nous neuro-symbolic AI library. Full implementation coming soon.
5
- Author-email: Islam Tlupov <tlupovislam@gmail.com>
6
- License: MIT License
7
-
8
- Copyright (c) 2025 Islam Tlupov
9
-
10
- Permission is hereby granted, free of charge, to any person obtaining a copy
11
- of this software and associated documentation files (the "Software"), to deal
12
- in the Software without restriction, including without limitation the rights
13
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
- copies of the Software, and to permit persons to whom the Software is
15
- furnished to do so, subject to the following conditions:
16
-
17
- The above copyright notice and this permission notice shall be included in all
18
- copies or substantial portions of the Software.
19
-
20
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
- SOFTWARE.
27
-
28
- Project-URL: Homepage, https://github.com/EmotionEngineer/nous
29
- Classifier: Development Status :: 1 - Planning
30
- Classifier: Intended Audience :: Developers
31
- Classifier: License :: OSI Approved :: MIT License
32
- Classifier: Programming Language :: Python :: 3
33
- Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
34
- Requires-Python: >=3.8
35
- Description-Content-Type: text/markdown
36
- License-File: LICENSE
37
- Dynamic: license-file
38
-
39
- # nous
40
-
41
- **This package name is reserved for the Nous neuro-symbolic AI library.**
42
-
43
- A full implementation is in active development and will be published here soon.
@@ -1,8 +0,0 @@
1
- LICENSE
2
- README.md
3
- pyproject.toml
4
- nous/__init__.py
5
- nous.egg-info/PKG-INFO
6
- nous.egg-info/SOURCES.txt
7
- nous.egg-info/dependency_links.txt
8
- nous.egg-info/top_level.txt
nous-0.0.1/pyproject.toml DELETED
@@ -1,25 +0,0 @@
1
- # pyproject.toml
2
- [build-system]
3
- requires = ["setuptools>=61.0", "wheel"]
4
- build-backend = "setuptools.build_meta"
5
-
6
- [project]
7
- name = "nous"
8
- version = "0.0.1"
9
- description = "Placeholder for the Nous neuro-symbolic AI library. Full implementation coming soon."
10
- readme = "README.md"
11
- requires-python = ">=3.8"
12
- license = { file = "LICENSE" }
13
- authors = [
14
- { name = "Islam Tlupov", email = "tlupovislam@gmail.com" },
15
- ]
16
- classifiers = [
17
- "Development Status :: 1 - Planning",
18
- "Intended Audience :: Developers",
19
- "License :: OSI Approved :: MIT License",
20
- "Programming Language :: Python :: 3",
21
- "Topic :: Scientific/Engineering :: Artificial Intelligence",
22
- ]
23
-
24
- [project.urls]
25
- Homepage = "https://github.com/EmotionEngineer/nous"
File without changes
File without changes
File without changes