Refactor and optimizations

This commit is contained in:
2026-09-08 19:06:02 +02:00
parent 8a50f492ac
commit 0e177f8491
11 changed files with 277 additions and 177 deletions
+3 -14
View File
@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Events\PerceptronInitialization; use App\Events\PerceptronInitialization;
use App\Http\Requests\RunPerceptronRequest;
use App\Models\NetworksTraining\ADALINEPerceptronTraining; use App\Models\NetworksTraining\ADALINEPerceptronTraining;
use App\Models\NetworksTraining\GradientDescentPerceptronTraining; use App\Models\NetworksTraining\GradientDescentPerceptronTraining;
use App\Models\NetworksTraining\MonoLayerPerceptronTraining; use App\Models\NetworksTraining\MonoLayerPerceptronTraining;
@@ -18,7 +19,6 @@ use App\Services\SynapticWeightsProvider\RandomSynapticWeights;
use App\Services\SynapticWeightsProvider\ZeroSynapticWeights; use App\Services\SynapticWeightsProvider\ZeroSynapticWeights;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class PerceptronController extends Controller class PerceptronController extends Controller
{ {
@@ -168,20 +168,10 @@ class PerceptronController extends Controller
return new RandomOrderDataSetReader($dataSetFileName); return new RandomOrderDataSetReader($dataSetFileName);
} }
public function run(Request $request, ISynapticWeightsProvider $synapticWeightsProvider) public function run(RunPerceptronRequest $request, ISynapticWeightsProvider $synapticWeightsProvider)
{ {
$startTime = microtime(true); $startTime = microtime(true);
// Verifications
$validator = Validator::make($request->all(), config('perceptron.run_inputs_validation'));
if ($validator->fails()) {
return response()->json([
'message' => 'Invalid input parameters',
'errors' => $validator->errors(),
], 400);
}
$perceptronType = $request->input('type'); $perceptronType = $request->input('type');
$hiddenLayers = $request->input('hidden_layers', 2); $hiddenLayers = $request->input('hidden_layers', 2);
$hiddenLayersNeurons = $request->input('hidden_layers_neurons', 3); $hiddenLayersNeurons = $request->input('hidden_layers_neurons', 3);
@@ -199,8 +189,7 @@ class PerceptronController extends Controller
// Zero initialization prevents hidden layers from receiving a gradient. // Zero initialization prevents hidden layers from receiving a gradient.
if ($perceptronType === 'multilayer' && $weightInitMethod === 'zeros') { if ($perceptronType === 'multilayer' && $weightInitMethod === 'zeros') {
$synapticWeightsProvider = new RandomSynapticWeights; $synapticWeightsProvider = new RandomSynapticWeights;
} } elseif ($weightInitMethod === 'zeros') {
else if ($weightInitMethod === 'zeros') {
$synapticWeightsProvider = new ZeroSynapticWeights; $synapticWeightsProvider = new ZeroSynapticWeights;
} }
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RunPerceptronRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'type' => ['required', 'string', 'in:simple,gradientdescent,adaline,monolayer,multilayer'],
'dataset' => ['required', 'string', 'max:100', 'regex:/^[A-Za-z0-9_-]+$/'],
'weight_init_method' => ['required', 'string', 'in:random,zeros'],
'learning_rate' => ['required', 'numeric', 'min:0'],
'min_error' => ['required', 'numeric', 'min:0'],
'hidden_layers' => ['required', 'integer', 'min:1', 'max:5'],
'hidden_layers_neurons' => ['required', 'integer', 'min:1', 'max:5'],
'max_iterations' => ['required', 'integer', 'min:1', 'max:5000'],
'session_id' => ['required', 'string', 'max:100'],
'training_id' => ['required', 'string', 'max:100'],
];
}
}
@@ -8,7 +8,7 @@ class LinearOrderDataSetReader implements IDataSetReader
{ {
public array $lines = []; public array $lines = [];
private array $currentLines = []; private int $currentLineIndex = 0;
private int $lastReadLineIndex = -1; private int $lastReadLineIndex = -1;
@@ -35,13 +35,13 @@ class LinearOrderDataSetReader implements IDataSetReader
public function getNextLine(): ?array public function getNextLine(): ?array
{ {
if (! isset($this->currentLines[0])) { if (! isset($this->lines[$this->currentLineIndex])) {
return null; // No more lines to read return null; // No more lines to read
} }
$this->lastReadLineIndex = array_search($this->currentLines[0], $this->lines, true); $this->lastReadLineIndex = $this->currentLineIndex;
return array_shift($this->currentLines); return $this->lines[$this->currentLineIndex++];
} }
public function getInputSize(): int public function getInputSize(): int
@@ -53,18 +53,20 @@ class LinearOrderDataSetReader implements IDataSetReader
{ {
// Count the number of unique labels in the dataset // Count the number of unique labels in the dataset
$labels = array_map(fn ($line) => end($line), $this->lines); $labels = array_map(fn ($line) => end($line), $this->lines);
return count(array_unique($labels)); return count(array_unique($labels));
} }
public function getLabels(): array public function getLabels(): array
{ {
$labels = array_map(fn ($line) => end($line), $this->lines); $labels = array_map(fn ($line) => end($line), $this->lines);
return array_values(array_unique($labels)); return array_values(array_unique($labels));
} }
public function reset(): void public function reset(): void
{ {
$this->currentLines = $this->lines; $this->currentLineIndex = 0;
} }
public function getLastReadLineIndex(): int public function getLastReadLineIndex(): int
@@ -8,7 +8,9 @@ class RandomOrderDataSetReader implements IDataSetReader
{ {
public array $lines = []; public array $lines = [];
private array $currentLines = []; private array $currentLineIndexes = [];
private int $currentLineIndex = 0;
private int $lastReadLineIndex = -1; private int $lastReadLineIndex = -1;
@@ -35,19 +37,14 @@ class RandomOrderDataSetReader implements IDataSetReader
public function getNextLine(): ?array public function getNextLine(): ?array
{ {
if (empty($this->currentLines)) { if (! isset($this->currentLineIndexes[$this->currentLineIndex])) {
return null; // No more lines to read return null; // No more lines to read
} }
$randomNumber = array_rand($this->currentLines); $lineIndex = $this->currentLineIndexes[$this->currentLineIndex++];
$randomLine = $this->currentLines[$randomNumber];
// Remove the line from the current lines to avoid repetition $this->lastReadLineIndex = $lineIndex;
unset($this->currentLines[$randomNumber]);
// Remember the index of the last read line in the full list return $this->lines[$lineIndex];
$this->lastReadLineIndex = array_search($randomLine, $this->lines, true);
return $randomLine;
} }
public function getInputSize(): int public function getInputSize(): int
@@ -59,18 +56,22 @@ class RandomOrderDataSetReader implements IDataSetReader
{ {
// Count the number of unique labels in the dataset // Count the number of unique labels in the dataset
$labels = array_map(fn ($line) => end($line), $this->lines); $labels = array_map(fn ($line) => end($line), $this->lines);
return count(array_unique($labels)); return count(array_unique($labels));
} }
public function getLabels(): array public function getLabels(): array
{ {
$labels = array_map(fn ($line) => end($line), $this->lines); $labels = array_map(fn ($line) => end($line), $this->lines);
return array_values(array_unique($labels)); return array_values(array_unique($labels));
} }
public function reset(): void public function reset(): void
{ {
$this->currentLines = $this->lines; $this->currentLineIndexes = array_keys($this->lines);
shuffle($this->currentLineIndexes);
$this->currentLineIndex = 0;
} }
public function getLastReadLineIndex(): int public function getLastReadLineIndex(): int
@@ -2,27 +2,24 @@
namespace App\Services\IterationEventBuffer; namespace App\Services\IterationEventBuffer;
use App\Events\PerceptronTrainingIteration;
class PerceptronIterationEventBuffer implements IPerceptronIterationEventBuffer class PerceptronIterationEventBuffer implements IPerceptronIterationEventBuffer
{ {
private $data; private array $data = [];
private int $nextSizeIncreaseThreshold;
private int $underSizeIncreaseCount = 0;
public function __construct( public function __construct(
private string $sessionId, private string $sessionId,
private string $trainingId, private string $trainingId,
private int $sizeIncreaseStart = 10, ) {}
private int $sizeIncreaseFactor = 2,
) {
$this->data = [];
$this->nextSizeIncreaseThreshold = $sizeIncreaseStart;
}
public function flush(): void public function flush(): void
{ {
event(new \App\Events\PerceptronTrainingIteration($this->data, $this->sessionId, $this->trainingId)); if ($this->data === []) {
return;
}
event(new PerceptronTrainingIteration($this->data, $this->sessionId, $this->trainingId));
$this->data = []; $this->data = [];
} }
@@ -35,27 +32,24 @@ class PerceptronIterationEventBuffer implements IPerceptronIterationEventBuffer
'weights' => $synaptic_weights, 'weights' => $synaptic_weights,
]; ];
$payload = [
'iterations' => [...$this->data, $iteration],
'trainingId' => $this->trainingId,
];
if ($this->data !== [] && strlen(json_encode($payload, JSON_THROW_ON_ERROR)) > config('broadcasting.broadcast_max_payload_size')) {
$this->flush();
}
$this->data[] = $iteration; $this->data[] = $iteration;
if ($this->underSizeIncreaseCount <= $this->sizeIncreaseStart) { // We can still send a single date because we are under the increase start threshold if ($this->data !== [] && $this->payloadExceedsLimit()) {
$this->underSizeIncreaseCount++; $lastIteration = array_pop($this->data);
$this->flush(); $this->flush();
} elseif (count($this->data) >= $this->nextSizeIncreaseThreshold) { $this->data[] = $lastIteration;
$this->flush(); }
$this->nextSizeIncreaseThreshold *= $this->sizeIncreaseFactor;
if ($this->nextSizeIncreaseThreshold > config('perceptron.broadcast_iteration_size')) { if (count($this->data) >= config('perceptron.broadcast_iteration_size')) {
$this->nextSizeIncreaseThreshold = config('perceptron.broadcast_iteration_size'); // Cap the threshold to the maximum size $this->flush();
} }
} }
private function payloadExceedsLimit(): bool
{
return strlen(json_encode([
'iterations' => $this->data,
'trainingId' => $this->trainingId,
], JSON_THROW_ON_ERROR)) > config('broadcasting.broadcast_max_payload_size');
} }
} }
@@ -2,24 +2,29 @@
namespace App\Services\IterationEventBuffer; namespace App\Services\IterationEventBuffer;
use App\Events\PerceptronTrainingIteration;
class PerceptronLimitedEpochEventBuffer implements IPerceptronIterationEventBuffer class PerceptronLimitedEpochEventBuffer implements IPerceptronIterationEventBuffer
{ {
private array $data; private array $data = [];
private int $underSizeIncreaseCount = 0; private ?int $activeEpoch = null;
private bool $shouldBroadcastEpoch = false;
public function __construct( public function __construct(
private string $sessionId, private string $sessionId,
private string $trainingId, private string $trainingId,
private int $epochInterval, private int $epochInterval,
private int $sizeIncreaseStart = 10, ) {}
) {
$this->data = [];
}
public function flush(): void public function flush(): void
{ {
event(new \App\Events\PerceptronTrainingIteration($this->data, $this->sessionId, $this->trainingId)); if ($this->data === []) {
return;
}
event(new PerceptronTrainingIteration($this->data, $this->sessionId, $this->trainingId));
$this->data = []; $this->data = [];
} }
@@ -32,16 +37,28 @@ class PerceptronLimitedEpochEventBuffer implements IPerceptronIterationEventBuff
'weights' => $synaptic_weights, 'weights' => $synaptic_weights,
]; ];
$lastEpoch = $this->data[0]['epoch'] ?? null; if ($this->activeEpoch !== $epoch) {
if ($this->data && $lastEpoch !== $epoch) { // Current Epoch has changed from the last one $this->flush();
if ($lastEpoch == 1 || $lastEpoch % $this->epochInterval === 0) { // The last saved epoch need to be sent $this->activeEpoch = $epoch;
$this->flush(); // Flush all data from the previous epoch $this->shouldBroadcastEpoch = $epoch === 1 || $epoch % $this->epochInterval === 0;
} else {
$this->data = []; // We clear the data without sending it as we are saving the next epoch data
} }
$lastEpoch = $epoch; if (! $this->shouldBroadcastEpoch) {
return;
} }
$this->data[] = $newData; $this->data[] = $newData;
if ($this->payloadExceedsLimit() || count($this->data) >= config('perceptron.broadcast_iteration_size')) {
$this->flush();
}
}
private function payloadExceedsLimit(): bool
{
return strlen(json_encode([
'iterations' => $this->data,
'trainingId' => $this->trainingId,
], JSON_THROW_ON_ERROR)) > config('broadcasting.broadcast_max_payload_size');
} }
} }
-4
View File
@@ -1,4 +0,0 @@
0, 0, -1
0, 1, 1
1, 0, 1
1, 1, 1
1 0 0 -1
2 0 1 1
3 1 0 1
4 1 1 1
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ChartData } from 'chart.js'; import type { ChartDataset } from 'chart.js';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { Bar } from 'vue-chartjs'; import { Chart } from 'vue-chartjs';
import { colors, gridColor, gridColorBold } from '@/types/graphs'; import { colors, gridColor, gridColorBold } from '@/types/graphs';
import type { Iteration } from '@/types/perceptron'; import type { Iteration } from '@/types/perceptron';
import Toggle from './ui/toggle/Toggle.vue'; import Toggle from './ui/toggle/Toggle.vue';
@@ -11,16 +11,16 @@ const props = defineProps<{
isRegression: boolean; isRegression: boolean;
}>(); }>();
type ErrorValue = number | [number, number] | null;
type ErrorDataset = ChartDataset<'bar' | 'line', ErrorValue[]>;
const epochErrorOnly = ref<boolean>(false); const epochErrorOnly = ref<boolean>(false);
/** /**
* Datasets of the iterations with the form { label: `Exemple ${exampleIndex}`, data: [error for iteration 1, error for iteration 2, ...] } * Datasets of the iterations with the form { label: `Exemple ${exampleIndex}`, data: [error for iteration 1, error for iteration 2, ...] }
*/ */
const datasets = computed< const datasets = computed<ErrorDataset[]>(() => {
ChartData<'bar', (number | [number, number] | null)[]>[] const datasets: ErrorDataset[] = [];
>(() => {
const datasets: ChartData<'bar', (number | [number, number] | null)[]>[] =
[];
const epochAverageError: number[] = []; const epochAverageError: number[] = [];
const backgroundColors = colors; const backgroundColors = colors;
@@ -60,14 +60,14 @@ const datasets = computed<
// Sort dataset by label (Exemple 0, Exemple 1, ...) // Sort dataset by label (Exemple 0, Exemple 1, ...)
datasets.sort((a, b) => { datasets.sort((a, b) => {
const aIndex = parseInt(a.label.split(' ')[1]); const aIndex = parseInt((a.label ?? '').split(' ')[1]);
const bIndex = parseInt(b.label.split(' ')[1]); const bIndex = parseInt((b.label ?? '').split(' ')[1]);
return aIndex - bIndex; return aIndex - bIndex;
}); });
// Epoch error // Epoch error
const epochErrorDataset = { const epochErrorDataset: ErrorDataset = {
type: 'line', type: 'line' as const,
label: "Erreur quadratique moyenne de l'époque", label: "Erreur quadratique moyenne de l'époque",
data: [], data: [],
backgroundColor: '#fff', backgroundColor: '#fff',
@@ -88,7 +88,8 @@ const datasets = computed<
</script> </script>
<template> <template>
<Bar <Chart
type="bar"
class="bg-primary dark:bg-transparent!" class="bg-primary dark:bg-transparent!"
:options="{ :options="{
responsive: true, responsive: true,
@@ -0,0 +1,104 @@
import { useEcho } from '@laravel/echo-vue';
import { onBeforeUnmount, ref, shallowRef } from 'vue';
import type { Iteration } from '@/types/perceptron';
type TrainingEvent = {
trainingId: string;
};
type IterationEvent = TrainingEvent & {
iterations: Iteration[];
};
type InitializationEvent = TrainingEvent & {
activationFunction: string;
};
type TrainingEndedEvent = TrainingEvent & {
reason: string;
};
export function usePerceptronTraining(sessionId: string) {
const trainingId = ref('');
const iterations = shallowRef<Iteration[]>([]);
const trainingEnded = ref(false);
const trainingEndReason = ref('');
const activationFunction = ref('');
let pendingIterations: Iteration[] = [];
let renderFrame: number | null = null;
function isCurrentTraining(event: TrainingEvent): boolean {
return event.trainingId === trainingId.value;
}
function flushPendingIterations(): void {
renderFrame = null;
if (pendingIterations.length === 0) {
return;
}
iterations.value = [...iterations.value, ...pendingIterations];
pendingIterations = [];
}
function handleIterations(event: IterationEvent): void {
if (!isCurrentTraining(event)) {
return;
}
pendingIterations.push(...event.iterations);
if (renderFrame === null) {
renderFrame = requestAnimationFrame(flushPendingIterations);
}
}
function handleInitialization(event: InitializationEvent): void {
if (isCurrentTraining(event)) {
activationFunction.value = event.activationFunction;
}
}
function handleTrainingEnded(event: TrainingEndedEvent): void {
if (!isCurrentTraining(event)) {
return;
}
flushPendingIterations();
trainingEnded.value = true;
trainingEndReason.value = event.reason;
}
function reset(): void {
if (renderFrame !== null) {
cancelAnimationFrame(renderFrame);
renderFrame = null;
}
pendingIterations = [];
iterations.value = [];
trainingEnded.value = false;
trainingEndReason.value = '';
activationFunction.value = '';
}
function setTrainingId(newTrainingId: string): void {
reset();
trainingId.value = newTrainingId;
}
const channel = `${sessionId}-perceptron-training`;
useEcho(channel, 'PerceptronTrainingIteration', handleIterations, [{}], 'public');
useEcho(channel, 'PerceptronTrainingEnded', handleTrainingEnded, [{}], 'public');
useEcho(channel, 'PerceptronInitialization', handleInitialization, [{}], 'public');
onBeforeUnmount(reset);
return {
activationFunction,
iterations,
setTrainingId,
trainingEnded,
trainingEndReason,
};
}
+11 -85
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { useEcho } from '@laravel/echo-vue';
import { import {
Chart as ChartJS, Chart as ChartJS,
Title, Title,
@@ -14,13 +13,8 @@ import {
} from 'chart.js'; } from 'chart.js';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import LinkHeader from '@/components/LinkHeader.vue'; import LinkHeader from '@/components/LinkHeader.vue';
import type { import { usePerceptronTraining } from '@/composables/usePerceptronTraining';
Dataset, import type { Dataset, DatasetPoint, InitializationMethod, PerceptronType } from '@/types/perceptron';
DatasetPoint,
InitializationMethod,
Iteration,
PerceptronType,
} from '@/types/perceptron';
import IterationTable from '../components/IterationTable.vue'; import IterationTable from '../components/IterationTable.vue';
import PerceptronDecisionGraph from '../components/PerceptronDecisionGraph.vue'; import PerceptronDecisionGraph from '../components/PerceptronDecisionGraph.vue';
import PerceptronIterationsErrorsGraph from '../components/PerceptronIterationsErrorsGraph.vue'; import PerceptronIterationsErrorsGraph from '../components/PerceptronIterationsErrorsGraph.vue';
@@ -86,74 +80,16 @@ const cleanedDataset = computed<
const hiddenLayers = ref(3); const hiddenLayers = ref(3);
const hiddenLayersNeurons = ref(3); const hiddenLayersNeurons = ref(3);
const initializationMethod = ref<InitializationMethod>(props.type === 'multilayer' ? 'random' : 'zeros'); const initializationMethod = ref<InitializationMethod>(props.type === 'multilayer' ? 'random' : 'zeros');
const {
console.log('Session ID:', props.sessionId); activationFunction,
iterations,
useEcho( setTrainingId,
`${props.sessionId}-perceptron-training`, trainingEnded,
'PerceptronTrainingIteration', trainingEndReason,
percpetronIteration, } = usePerceptronTraining(props.sessionId);
[{}],
'public',
);
useEcho(
`${props.sessionId}-perceptron-training`,
'PerceptronTrainingEnded',
perceptronTrainingEnded,
[{}],
'public',
);
useEcho(
`${props.sessionId}-perceptron-training`,
'PerceptronInitialization',
perceptroninitialization,
[{}],
'public',
);
const iterations = ref<Iteration[]>([]);
const trainingId = ref<string>('');
function percpetronIteration(data: any) {
console.log('Received perceptron iteration data:', data);
if (data.trainingId !== trainingId.value) {
console.warn(
`Received iteration for training ID ${data.trainingId}, but current training ID is ${trainingId.value}. Ignoring this iteration.`
);
return;
}
iterations.value.push(...data.iterations);
}
const trainingEnded = ref(false);
const trainingEndReason = ref('');
function perceptronTrainingEnded(data: any) {
console.log('Perceptron training ended:', data);
if (data.trainingId !== trainingId.value) {
console.warn(
`Received training ended event for training ID ${data.trainingId}, but current training ID is ${trainingId.value}. Ignoring this event.`
);
return;
}
trainingEnded.value = true;
trainingEndReason.value = data.reason;
}
const activationFunction = ref<string>('');
const isRegression = computed( const isRegression = computed(
() => props.type === 'multilayer' && activationFunction.value === 'linear', () => (props.type === 'multilayer' || props.type === 'monolayer') && activationFunction.value === 'linear',
); );
function perceptroninitialization(data: any) {
console.log('Perceptron training initialized:', data);
if (data.trainingId !== trainingId.value) {
console.warn(
`Received initialization event for training ID ${data.trainingId}, but current training ID is ${trainingId.value}. Ignoring this event.`
);
return;
}
activationFunction.value = data.activationFunction;
}
function getActivationFunction(type: string): (x: number) => number { function getActivationFunction(type: string): (x: number) => number {
switch (type) { switch (type) {
case 'step': case 'step':
@@ -169,12 +105,6 @@ function getActivationFunction(type: string): (x: number) => number {
} }
} }
function resetTraining() {
iterations.value = [];
trainingEnded.value = false;
trainingEndReason.value = '';
activationFunction.value = '';
}
</script> </script>
<template> <template>
@@ -197,11 +127,7 @@ function resetTraining() {
selectedDatasetName = newValue; selectedDatasetName = newValue;
} }
" "
@update:training-id=" @update:training-id="setTrainingId"
(newValue) => {
trainingId = newValue;
resetTraining();
}"
/> />
<div <div
class="align-items-start justify-content-center flex h-full min-h-dvh max-w-dvw" class="align-items-start justify-content-center flex h-full min-h-dvh max-w-dvw"
@@ -0,0 +1,41 @@
<?php
namespace Tests\Unit\Services;
use App\Events\PerceptronTrainingIteration;
use App\Services\IterationEventBuffer\PerceptronIterationEventBuffer;
use App\Services\IterationEventBuffer\PerceptronLimitedEpochEventBuffer;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class IterationEventBufferTest extends TestCase
{
public function test_iterations_are_sent_as_a_single_batch(): void
{
Event::fake();
$buffer = new PerceptronIterationEventBuffer('session', 'training');
$buffer->addIteration(1, 0, 0.5, []);
$buffer->addIteration(1, 1, 0.25, []);
$buffer->flush();
Event::assertDispatched(PerceptronTrainingIteration::class, function (PerceptronTrainingIteration $event): bool {
return count($event->iterations) === 2;
});
}
public function test_limited_buffer_discards_non_selected_epochs(): void
{
Event::fake();
$buffer = new PerceptronLimitedEpochEventBuffer('session', 'training', 2);
$buffer->addIteration(1, 0, 0.5, []);
$buffer->addIteration(2, 0, 0.25, []);
$buffer->flush();
Event::assertDispatched(PerceptronTrainingIteration::class, function (PerceptronTrainingIteration $event): bool {
return count($event->iterations) === 1
&& $event->iterations[0]['epoch'] === 1;
});
}
}