Harry00 commited on
Commit
949842e
·
verified ·
1 Parent(s): 0336663

Upload mle/memory.py

Browse files
Files changed (1) hide show
  1. mle/memory.py +540 -0
mle/memory.py ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sparse Address Table (SAT) - Mémoire adaptative à haute dimension
3
+
4
+ Vecteurs binaires de 4096 bits stockés sous forme de tableaux booléens uint8.
5
+ Supporte :
6
+ - Création dynamique de vecteurs pour configurations récurrentes
7
+ - Fusion/spécialisation de vecteurs
8
+ - Réorganisation locale pour cohérence sémantique
9
+ - Pruning contrôlé et normalisation
10
+ """
11
+
12
+ import numpy as np
13
+ from numba import njit, prange
14
+ from typing import List, Dict, Tuple, Optional, Set
15
+ import logging
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ VECTOR_SIZE = 4096
20
+ SLICE_SIZE = 64 # Pour opérations SIMD-friendly
21
+ NUM_SLICES = VECTOR_SIZE // SLICE_SIZE
22
+
23
+
24
+ @njit(parallel=True, cache=True)
25
+ def hamming_distance_batch(query: np.ndarray, table: np.ndarray) -> np.ndarray:
26
+ """
27
+ Calcule les distances de Hamming entre un vecteur requête et tous les vecteurs de la table.
28
+ Optimisé avec parallélisation numba.
29
+
30
+ Args:
31
+ query: (VECTOR_SIZE,) uint8 binaire
32
+ table: (N, VECTOR_SIZE) uint8 binaire
33
+
34
+ Returns:
35
+ distances: (N,) int32 Hamming distances
36
+ """
37
+ N = table.shape[0]
38
+ distances = np.empty(N, dtype=np.int32)
39
+ for i in prange(N):
40
+ dist = 0
41
+ for j in range(VECTOR_SIZE):
42
+ dist += query[j] ^ table[i, j]
43
+ distances[i] = dist
44
+ return distances
45
+
46
+
47
+ @njit(cache=True)
48
+ def bitwise_and_popcount(a: np.ndarray, b: np.ndarray) -> int:
49
+ """Compte les bits communs entre deux vecteurs binaires."""
50
+ count = 0
51
+ for i in range(len(a)):
52
+ count += a[i] & b[i]
53
+ return count
54
+
55
+
56
+ class VectorMetadata:
57
+ """Métadonnées associées à un vecteur de la SAT."""
58
+
59
+ def __init__(self, vector_id: int, creation_context: Optional[np.ndarray] = None):
60
+ self.id = vector_id
61
+ self.creation_time = 0
62
+ self.last_access = 0
63
+ self.access_count = 0
64
+ self.energy_history: List[float] = []
65
+ self.coactivation_neighbors: Dict[int, float] = {} # id -> weight
66
+ self.abstraction_level = 0 # 0 = concret, >0 = abstrait
67
+ self.merged_from: Optional[List[int]] = None
68
+ self.specialized_from: Optional[int] = None
69
+ self.creation_context = creation_context # snapshot du contexte à la création
70
+ self.stability_score = 1.0
71
+
72
+ def record_access(self, time_step: int, energy: float):
73
+ self.last_access = time_step
74
+ self.access_count += 1
75
+ self.energy_history.append(energy)
76
+ if len(self.energy_history) > 100:
77
+ self.energy_history = self.energy_history[-100:]
78
+
79
+ def update_coactivation(self, neighbor_id: int, strength: float, decay: float = 0.99):
80
+ """Met à jour le poids de coactivation avec un autre vecteur."""
81
+ if neighbor_id in self.coactivation_neighbors:
82
+ self.coactivation_neighbors[neighbor_id] = (
83
+ decay * self.coactivation_neighbors[neighbor_id] + (1 - decay) * strength
84
+ )
85
+ else:
86
+ self.coactivation_neighbors[neighbor_id] = strength
87
+
88
+ @property
89
+ def average_energy(self) -> float:
90
+ if not self.energy_history:
91
+ return 0.0
92
+ return np.mean(self.energy_history[-20:])
93
+
94
+ @property
95
+ def usage_score(self) -> float:
96
+ """Score combiné accès et stabilité pour décider pruning/fusion."""
97
+ recency = 1.0 / (1.0 + 0.001 * (self.last_access - self.creation_time))
98
+ return np.log1p(self.access_count) * recency * self.stability_score
99
+
100
+
101
+ class SparseAddressTable:
102
+ """
103
+ Table d'adresses sparse adaptative.
104
+
105
+ Stocke N vecteurs binaires de 4096 bits avec métadonnées dynamiques.
106
+ Supporte création, fusion, spécialisation, réorganisation locale.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ initial_capacity: int = 1000,
112
+ max_capacity: int = 50000,
113
+ sparsity_target: float = 0.05, # ~200 bits actifs sur 4096
114
+ creation_threshold: float = 0.3, # Distance relative pour création
115
+ fusion_threshold: float = 0.05, # Distance relative pour fusion
116
+ pruning_threshold: float = 0.01, # Score usage minimum
117
+ ):
118
+ self.vector_size = VECTOR_SIZE
119
+ self.sparsity_target = sparsity_target
120
+ self.target_active = int(VECTOR_SIZE * sparsity_target)
121
+ self.creation_threshold = int(VECTOR_SIZE * creation_threshold)
122
+ self.fusion_threshold = int(VECTOR_SIZE * fusion_threshold)
123
+ self.pruning_threshold = pruning_threshold
124
+ self.max_capacity = max_capacity
125
+
126
+ # Données
127
+ self.vectors: np.ndarray = np.zeros((initial_capacity, VECTOR_SIZE), dtype=np.uint8)
128
+ self.metadata: Dict[int, VectorMetadata] = {}
129
+ self.active_mask: np.ndarray = np.zeros(initial_capacity, dtype=bool)
130
+ self.next_id = 0
131
+ self.time_step = 0
132
+
133
+ # Stats
134
+ self.stats = {
135
+ 'creations': 0,
136
+ 'fusions': 0,
137
+ 'specializations': 0,
138
+ 'prunings': 0,
139
+ 'reorganizations': 0,
140
+ }
141
+
142
+ @property
143
+ def size(self) -> int:
144
+ return int(np.sum(self.active_mask))
145
+
146
+ @property
147
+ def active_vectors(self) -> np.ndarray:
148
+ """Retourne uniquement les vecteurs actifs."""
149
+ return self.vectors[self.active_mask]
150
+
151
+ @property
152
+ def active_ids(self) -> List[int]:
153
+ return [i for i, m in self.metadata.items() if self.active_mask[i]]
154
+
155
+ def _expand_if_needed(self):
156
+ """Agrandit le stockage si nécessaire."""
157
+ if self.size >= self.vectors.shape[0] - 10:
158
+ new_size = min(int(self.vectors.shape[0] * 1.5), self.max_capacity)
159
+ if new_size > self.vectors.shape[0]:
160
+ new_vectors = np.zeros((new_size, VECTOR_SIZE), dtype=np.uint8)
161
+ new_vectors[:self.vectors.shape[0]] = self.vectors
162
+ self.vectors = new_vectors
163
+
164
+ new_mask = np.zeros(new_size, dtype=bool)
165
+ new_mask[:self.active_mask.shape[0]] = self.active_mask
166
+ self.active_mask = new_mask
167
+
168
+ def _create_sparse_vector(self, seed_context: Optional[np.ndarray] = None) -> np.ndarray:
169
+ """Crée un nouveau vecteur sparse aléatoire avec sparsité cible."""
170
+ vec = np.zeros(VECTOR_SIZE, dtype=np.uint8)
171
+ if seed_context is not None:
172
+ # Biaisé par le contexte
173
+ n_from_context = min(self.target_active // 2, np.sum(seed_context))
174
+ if n_from_context > 0:
175
+ active_indices = np.where(seed_context)[0]
176
+ chosen = np.random.choice(active_indices, size=n_from_context, replace=False)
177
+ vec[chosen] = 1
178
+ # Complète aléatoirement
179
+ remaining = self.target_active - n_from_context
180
+ if remaining > 0:
181
+ zero_indices = np.where(vec == 0)[0]
182
+ if len(zero_indices) >= remaining:
183
+ chosen = np.random.choice(zero_indices, size=remaining, replace=False)
184
+ vec[chosen] = 1
185
+ else:
186
+ # Purement aléatoire
187
+ indices = np.random.choice(VECTOR_SIZE, size=self.target_active, replace=False)
188
+ vec[indices] = 1
189
+ return vec
190
+
191
+ def create_vector(
192
+ self,
193
+ context: Optional[np.ndarray] = None,
194
+ abstraction_level: int = 0,
195
+ metadata_override: Optional[Dict] = None
196
+ ) -> int:
197
+ """
198
+ Crée un nouveau vecteur dans la table.
199
+
200
+ Returns:
201
+ vector_id
202
+ """
203
+ self._expand_if_needed()
204
+
205
+ # Trouve le premier slot libre
206
+ free_slots = np.where(~self.active_mask)[0]
207
+ if len(free_slots) == 0:
208
+ # Forced pruning
209
+ self.prune_weakest(0.1)
210
+ free_slots = np.where(~self.active_mask)[0]
211
+ if len(free_slots) == 0:
212
+ raise RuntimeError("SAT pleine, impossible de créer un vecteur")
213
+
214
+ idx = free_slots[0]
215
+ vec = self._create_sparse_vector(context)
216
+ self.vectors[idx] = vec
217
+ self.active_mask[idx] = True
218
+
219
+ meta = VectorMetadata(self.next_id, creation_context=context.copy() if context is not None else None)
220
+ meta.creation_time = self.time_step
221
+ meta.abstraction_level = abstraction_level
222
+ if metadata_override:
223
+ for k, v in metadata_override.items():
224
+ setattr(meta, k, v)
225
+
226
+ self.metadata[idx] = meta
227
+ vector_id = self.next_id
228
+ self.next_id += 1
229
+
230
+ self.stats['creations'] += 1
231
+ logger.debug(f"Created vector {vector_id} at index {idx}")
232
+ return vector_id
233
+
234
+ def find_nearest(
235
+ self,
236
+ query: np.ndarray,
237
+ k: int = 5,
238
+ exclude_id: Optional[int] = None
239
+ ) -> List[Tuple[int, float, int]]:
240
+ """
241
+ Trouve les k vecteurs les plus proches par distance de Hamming.
242
+
243
+ Returns:
244
+ List of (vector_id, distance, index)
245
+ """
246
+ active = self.active_vectors
247
+ if len(active) == 0:
248
+ return []
249
+
250
+ distances = hamming_distance_batch(query, active)
251
+ active_indices = np.where(self.active_mask)[0]
252
+
253
+ # Trie
254
+ sorted_idx = np.argsort(distances)[:k]
255
+ results = []
256
+ for si in sorted_idx:
257
+ idx = active_indices[si]
258
+ meta = self.metadata[idx]
259
+ if exclude_id is not None and meta.id == exclude_id:
260
+ continue
261
+ results.append((meta.id, float(distances[si]), idx))
262
+
263
+ return results
264
+
265
+ def query_or_create(
266
+ self,
267
+ pattern: np.ndarray,
268
+ min_distance_threshold: Optional[float] = None
269
+ ) -> Tuple[int, int, bool]:
270
+ """
271
+ Requête : si un vecteur proche existe, le retourne.
272
+ Sinon, crée un nouveau vecteur.
273
+
274
+ Returns:
275
+ (vector_id, index, created)
276
+ """
277
+ threshold = min_distance_threshold or self.creation_threshold
278
+
279
+ nearest = self.find_nearest(pattern, k=1)
280
+ if nearest and nearest[0][1] < threshold:
281
+ vid, dist, idx = nearest[0]
282
+ meta = self.metadata[idx]
283
+ meta.record_access(self.time_step, energy=dist)
284
+ return vid, idx, False
285
+
286
+ # Crée un nouveau vecteur
287
+ vid = self.create_vector(context=pattern)
288
+ # Trouve son index
289
+ for idx, meta in self.metadata.items():
290
+ if meta.id == vid:
291
+ return vid, idx, True
292
+ return vid, -1, True
293
+
294
+ def fuse_vectors(self, id1: int, id2: int) -> Optional[int]:
295
+ """
296
+ Fusionne deux vecteurs proches en un nouveau vecteur.
297
+ Retourne l'ID du vecteur fusionné.
298
+ """
299
+ # Trouve les indices
300
+ idx1 = idx2 = -1
301
+ for idx, meta in self.metadata.items():
302
+ if meta.id == id1:
303
+ idx1 = idx
304
+ elif meta.id == id2:
305
+ idx2 = idx
306
+
307
+ if idx1 == -1 or idx2 == -1:
308
+ return None
309
+
310
+ v1 = self.vectors[idx1]
311
+ v2 = self.vectors[idx2]
312
+
313
+ # Distance de Hamming
314
+ dist = np.sum(v1 != v2)
315
+ if dist > self.fusion_threshold * 3:
316
+ logger.debug(f"Vectors {id1} and {id2} too far ({dist}), skip fusion")
317
+ return None
318
+
319
+ # Fusion : intersection majoritaire
320
+ merged = (v1 & v2) | (np.random.random(VECTOR_SIZE) < 0.5) & (v1 | v2)
321
+ merged = merged.astype(np.uint8)
322
+
323
+ # Ajuste la sparsité
324
+ active_count = np.sum(merged)
325
+ if active_count > self.target_active * 1.2:
326
+ excess = active_count - self.target_active
327
+ ones = np.where(merged)[0]
328
+ to_remove = np.random.choice(ones, size=excess, replace=False)
329
+ merged[to_remove] = 0
330
+ elif active_count < self.target_active * 0.8:
331
+ deficit = self.target_active - active_count
332
+ zeros = np.where(merged == 0)[0]
333
+ to_add = np.random.choice(zeros, size=deficit, replace=False)
334
+ merged[to_add] = 1
335
+
336
+ # Crée le nouveau vecteur
337
+ new_vid = self.create_vector(context=merged)
338
+ new_idx = -1
339
+ for idx, meta in self.metadata.items():
340
+ if meta.id == new_vid:
341
+ new_idx = idx
342
+ meta.merged_from = [id1, id2]
343
+ meta.stability_score = 0.5 # Nouveau, pas encore stabilisé
344
+ break
345
+
346
+ # Marque les anciens comme inactifs
347
+ self.active_mask[idx1] = False
348
+ self.active_mask[idx2] = False
349
+ del self.metadata[idx1]
350
+ del self.metadata[idx2]
351
+
352
+ self.stats['fusions'] += 1
353
+ logger.info(f"Fused {id1} + {id2} -> {new_vid}")
354
+ return new_vid
355
+
356
+ def specialize_vector(self, vector_id: int, context: np.ndarray, strength: float = 0.3) -> Optional[int]:
357
+ """
358
+ Crée une spécialisation d'un vecteur existant dans un contexte spécifique.
359
+ """
360
+ idx = -1
361
+ for i, meta in self.metadata.items():
362
+ if meta.id == vector_id:
363
+ idx = i
364
+ break
365
+ if idx == -1:
366
+ return None
367
+
368
+ original = self.vectors[idx]
369
+ # Mélange avec le contexte
370
+ specialized = original.copy()
371
+ context_active = np.where(context)[0]
372
+ n_to_flip = int(len(context_active) * strength)
373
+ if n_to_flip > 0:
374
+ to_flip = np.random.choice(context_active, size=n_to_flip, replace=False)
375
+ specialized[to_flip] = 1
376
+
377
+ # Ajuste sparsité
378
+ active = np.sum(specialized)
379
+ if active > self.target_active * 1.2:
380
+ ones = np.where(specialized)[0]
381
+ excess = active - self.target_active
382
+ to_remove = np.random.choice(ones, size=excess, replace=False)
383
+ specialized[to_remove] = 0
384
+
385
+ new_vid = self.create_vector(context=specialized)
386
+ for i, meta in self.metadata.items():
387
+ if meta.id == new_vid:
388
+ meta.specialized_from = vector_id
389
+ meta.abstraction_level = self.metadata[idx].abstraction_level + 1
390
+ break
391
+
392
+ self.stats['specializations'] += 1
393
+ return new_vid
394
+
395
+ def local_reorganization(self, center_idx: int, radius: int = 5):
396
+ """
397
+ Réorganise localement l'espace autour d'un vecteur central.
398
+ Déplace les vecteurs sémantiquement proches plus près dans l'espace
399
+ en ajustant légèrement leurs patterns.
400
+ """
401
+ if not self.active_mask[center_idx]:
402
+ return
403
+
404
+ center_vec = self.vectors[center_idx]
405
+ active_indices = np.where(self.active_mask)[0]
406
+
407
+ # Distance aux autres vecteurs actifs
408
+ distances = []
409
+ for idx in active_indices:
410
+ if idx == center_idx:
411
+ continue
412
+ dist = np.sum(center_vec != self.vectors[idx])
413
+ distances.append((idx, dist))
414
+
415
+ # Trie par distance
416
+ distances.sort(key=lambda x: x[1])
417
+
418
+ # Pour les vecteurs dans le voisinage proche, ajuste légèrement
419
+ n_neighbors = min(radius, len(distances))
420
+ for i in range(n_neighbors):
421
+ idx, dist = distances[i]
422
+ neighbor_vec = self.vectors[idx]
423
+
424
+ # Différence
425
+ diff = center_vec != neighbor_vec
426
+ diff_indices = np.where(diff)[0]
427
+
428
+ if len(diff_indices) > 0:
429
+ # Fait converger légèrement vers le centre
430
+ n_to_converge = max(1, len(diff_indices) // 10)
431
+ to_converge = np.random.choice(diff_indices, size=n_to_converge, replace=False)
432
+ self.vectors[idx, to_converge] = center_vec[to_converge]
433
+
434
+ # Met à jour les métadonnées
435
+ self.metadata[idx].stability_score *= 0.95
436
+
437
+ self.stats['reorganizations'] += 1
438
+
439
+ def prune_weakest(self, fraction: float = 0.05):
440
+ """
441
+ Supprime les vecteurs les moins utilisés.
442
+ """
443
+ if self.size == 0:
444
+ return
445
+
446
+ scores = []
447
+ for idx, meta in self.metadata.items():
448
+ scores.append((idx, meta.usage_score))
449
+
450
+ scores.sort(key=lambda x: x[1])
451
+ n_to_prune = max(1, int(len(scores) * fraction))
452
+
453
+ for idx, _ in scores[:n_to_prune]:
454
+ if self.active_mask[idx]:
455
+ self.active_mask[idx] = False
456
+ del self.metadata[idx]
457
+ self.stats['prunings'] += 1
458
+
459
+ logger.info(f"Pruned {n_to_prune} weak vectors")
460
+
461
+ def detect_frequent_patterns(self, trajectory: List[np.ndarray], min_frequency: int = 3) -> List[np.ndarray]:
462
+ """
463
+ Détecte les motifs fréquents dans une trajectoire de vecteurs.
464
+ Retourne des patterns candidats pour abstraction.
465
+ """
466
+ if len(trajectory) < min_frequency:
467
+ return []
468
+
469
+ # Cherche les sous-ensembles qui apparaissent fréquemment
470
+ # Simplifié : cherche les bits qui sont souvent actifs ensemble
471
+ trajectory_array = np.array(trajectory)
472
+ frequency = np.mean(trajectory_array, axis=0)
473
+
474
+ # Bits fréquemment actifs (>70% de la trajectoire)
475
+ common_bits = np.where(frequency > 0.7)[0]
476
+
477
+ if len(common_bits) < self.target_active // 4:
478
+ return []
479
+
480
+ # Crée un pattern abstrait
481
+ pattern = np.zeros(VECTOR_SIZE, dtype=np.uint8)
482
+ # Sélectionne un sous-ensemble des bits communs
483
+ n_select = min(self.target_active, len(common_bits))
484
+ selected = np.random.choice(common_bits, size=n_select, replace=False)
485
+ pattern[selected] = 1
486
+
487
+ return [pattern]
488
+
489
+ def tick(self):
490
+ """Incrémente le compteur de temps et effectue maintenance périodique."""
491
+ self.time_step += 1
492
+
493
+ # Maintenance périodique
494
+ if self.time_step % 1000 == 0:
495
+ self.prune_weakest(0.02)
496
+
497
+ if self.time_step % 500 == 0 and self.size > 100:
498
+ # Fusion périodique des paires très proches
499
+ self._periodic_fusion()
500
+
501
+ def _periodic_fusion(self):
502
+ """Fusionne automatiquement les paires de vecteurs très proches."""
503
+ active = np.where(self.active_mask)[0]
504
+ if len(active) < 2:
505
+ return
506
+
507
+ # Échantillonne aléatoirement pour efficacité O(N) au lieu de O(N²)
508
+ sample_size = min(50, len(active))
509
+ sampled = np.random.choice(active, size=sample_size, replace=False)
510
+
511
+ fused = set()
512
+ for i, idx1 in enumerate(sampled):
513
+ if idx1 in fused:
514
+ continue
515
+ for idx2 in sampled[i+1:]:
516
+ if idx2 in fused:
517
+ continue
518
+ dist = np.sum(self.vectors[idx1] != self.vectors[idx2])
519
+ if dist < self.fusion_threshold:
520
+ id1 = self.metadata[idx1].id
521
+ id2 = self.metadata[idx2].id
522
+ new_id = self.fuse_vectors(id1, id2)
523
+ if new_id is not None:
524
+ fused.add(idx1)
525
+ fused.add(idx2)
526
+ break
527
+
528
+ def get_stats(self) -> Dict:
529
+ """Retourne les statistiques de la mémoire."""
530
+ stats = self.stats.copy()
531
+ stats['size'] = self.size
532
+ stats['capacity'] = self.vectors.shape[0]
533
+ stats['time_step'] = self.time_step
534
+
535
+ if self.size > 0:
536
+ active = self.active_vectors
537
+ stats['avg_sparsity'] = float(np.mean(np.sum(active, axis=1)) / VECTOR_SIZE)
538
+ stats['avg_usage'] = float(np.mean([m.usage_score for m in self.metadata.values()]))
539
+
540
+ return stats