diff --git a/altai-objects/altai_objects.cpp b/altai-objects/altai_objects.cpp new file mode 100644 index 0000000..e8feaa0 --- /dev/null +++ b/altai-objects/altai_objects.cpp @@ -0,0 +1,515 @@ +// Object recognition entirely in KNP with AltAI neurons: retina → learned feature detectors → readout. +// +// altai_objects features +// Learns K feature detectors on 12×12 RGB crops (uint8, 3×12×12 each). Network: pixels → retina (fixed +// ON/OFF centre–surround neurons, 6 maps 6×6) → K competing AltAI neurons with trainable synapses. Each +// detector inhibits the others and its own spike gives it dopamine, so only winners learn; with the delta +// backend (KNP_DELTA_RULE, KNP_DELTA_DECAY, KNP_DELTA_LAG) the winner moves its weights towards the input. +// Writes float32 weights [K][216] (retina channel, y, x order). +// +// altai_objects run +// Full network: pixels (3×32×32) → retina (6×32×32) → detectors (K per position, stride 2, weights from +// features.bin minus their mean, integers) → readout (one neuron per class, delta-rule training with the +// label dopamine +a every active step and own-spike dopamine −b, KNP_DELTA=a,b). After images the +// readout weights are read back from the backend and fixed; then images are run with a plain AltAI +// readout. Writes _readout.bin (float32 [classes][neurons]) and _eval.csv +// (index,label,spike counts per class). +// +// Environment: KNP_RETINA_THR (default 250), KNP_DET_THR (detector threshold, default 2000), +// KNP_OUT_THR (readout threshold, default 4000), KNP_INHIB (detector lateral inhibition, default 5000). + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +using DeltaSynapse = knp::synapse_traits::DeltaSynapse; +using DeltaProjection = knp::core::Projection; +using ResourceSynapse = knp::synapse_traits::SynapticResourceSTDPDeltaSynapse; +using ResourceProjection = knp::core::Projection; +using AltAINeuron = knp::neuron_traits::AltAILIF; +using StdpNeuron = knp::neuron_traits::SynapticResourceSTDPAltAILIFNeuron; +using AltAIPopulation = knp::core::Population; +using StdpPopulation = knp::core::Population; + +constexpr int steps_per_image = 15, active_steps = 10, patch = 6, dog_r = 3, maps = 6; +constexpr float w_min = -253.122F, w_max = 92.3957F; + + +float env_f(const char *name, float def) +{ + const char *v = std::getenv(name); + return v ? std::stof(v) : def; +} + + +std::vector env_list(const char *name, std::vector def) +{ + const char *v = std::getenv(name); + if (!v) return def; + std::vector out; + std::string s(v); + size_t pos = 0; + while (pos <= s.size()) + { + size_t next = s.find(',', pos); + if (next == std::string::npos) next = s.size(); + out.push_back(std::stof(s.substr(pos, next - pos))); + pos = next + 1; + } + return out; +} + + +// Difference of Gaussians, sigma 0.5 and 1.5, 7×7, scaled by 1000 (as in altai-objects/retina_proto.py). +std::vector dog_kernel() +{ + auto gauss = [](float sigma) + { + std::vector g((2 * dog_r + 1) * (2 * dog_r + 1)); + float sum = 0; + for (int y = -dog_r; y <= dog_r; ++y) + for (int x = -dog_r; x <= dog_r; ++x) + sum += g[(y + dog_r) * (2 * dog_r + 1) + x + dog_r] = std::exp(-(x * x + y * y) / (2 * sigma * sigma)); + for (auto &v : g) v /= sum; + return g; + }; + auto a = gauss(0.5F), b = gauss(1.5F); + std::vector d(a.size()); + for (size_t i = 0; i < a.size(); ++i) d[i] = std::round((a[i] - b[i]) * 1000); + return d; +} + + +float resource_from_weight(float w) +{ + const double diff = w_max - w_min, over = w - w_min; + return static_cast(diff * over / (diff - over)); +} + + +knp::neuron_traits::neuron_parameters plain_neuron(float threshold) +{ + knp::neuron_traits::neuron_parameters p; + p.activation_threshold_ = static_cast(std::min(threshold, 65535.F)); + p.negative_activation_threshold_ = 0; + p.potential_reset_value_ = 0; + p.potential_leak_ = 0; + return p; +} + + +knp::neuron_traits::neuron_parameters learning_neuron(float threshold) +{ + knp::neuron_traits::neuron_parameters p; + p.activation_threshold_ = static_cast(std::min(threshold, 65535.F)); + p.negative_activation_threshold_ = 0; + p.potential_reset_value_ = 0; + p.potential_leak_ = 0; + p.dopamine_plasticity_time_ = 10; + p.isi_max_ = 10; + p.d_h_ = 0; + p.stability_change_parameter_ = 0; + p.synapse_sum_threshold_coefficient_ = 0; + return p; +} + + +// Retina projection: input channel (pixels c×h×w) → retina population (maps×oh×ow): ON map c uses +DoG, +// OFF map c uses −DoG around pixel (oy + off, ox + off) of channel c; taps outside the image are skipped. +DeltaProjection make_retina(const knp::core::UID &post, int h, int w, int oh, int ow, int off) +{ + const auto dog = dog_kernel(); + const int taps = (2 * dog_r + 1) * (2 * dog_r + 1); + std::vector synapses; + for (int m = 0; m < maps; ++m) + for (int oy = 0; oy < oh; ++oy) + for (int ox = 0; ox < ow; ++ox) + for (int t = 0; t < taps; ++t) + { + const int y = oy + off + t / (2 * dog_r + 1) - dog_r, x = ox + off + t % (2 * dog_r + 1) - dog_r; + if (y < 0 || y >= h || x < 0 || x >= w || dog[t] == 0) continue; + const int c = m % 3; + const float weight = m < 3 ? dog[t] : -dog[t]; + synapses.push_back(DeltaProjection::Synapse{ + {weight, 1, knp::synapse_traits::OutputType::EXCITATORY}, + static_cast(c * h * w + y * w + x), static_cast((m * oh + oy) * ow + ox)}); + } + return DeltaProjection( + knp::core::UID{false}, post, [&](size_t i) { return std::optional(synapses[i]); }, synapses.size()); +} + + +// Spike raster of an image (uint8 values), incrementing converter from rest, active steps only. +std::vector raster(const uint8_t *px, size_t n) +{ + std::vector out(active_steps); + std::vector st(n, 0.F); + for (int t = 0; t < active_steps; ++t) + for (size_t i = 0; i < n; ++i) + { + st[i] += px[i] / 255.F; + if (st[i] >= 1.F) + { + st[i] -= 1.F; + out[t].push_back(static_cast(i)); + } + } + return out; +} + + +std::vector read_bin(const std::string &path) +{ + std::ifstream f(path, std::ios::binary); + return std::vector((std::istreambuf_iterator(f)), std::istreambuf_iterator()); +} + + +// Read the trainable projection back from the backend: float weights [post][pre-index-in-population]. +template +std::vector trained_weights(Backend &backend, const knp::core::UID &proj_uid, size_t pre_n, size_t post_n) +{ + std::vector w(pre_n * post_n, 0.F); + auto data = backend->get_network_data(); + for (auto &it = *data.projection_range.first; it != *data.projection_range.second; ++it) + { + auto proj = *it; + std::visit( + [&](auto &p) + { + using P = std::decay_t; + if constexpr (std::is_same_v) + if (p.get_uid() == proj_uid) + for (const auto &s : p) + w[std::get<2>(s) * pre_n + std::get<1>(s)] = std::get(s).weight_; + }, + proj); + } + return w; +} + + +int features_mode(int argc, const char *const argv[]) +{ + const std::string patches_path = argv[2], out_path = argv[5], backend_path = argv[6]; + const size_t count = std::stoul(argv[3]), k = std::stoul(argv[4]); + constexpr int crop = 12, in_n = 3 * crop * crop, ret_n = maps * patch * patch; + const auto px = read_bin(patches_path); + if (px.size() < count * in_n) throw std::runtime_error("not enough patches"); + + AltAIPopulation retina([&](size_t) { return plain_neuron(env_f("KNP_RETINA_THR", 250)); }, ret_n); + StdpPopulation detectors([&](size_t) { return learning_neuron(env_f("KNP_DET_THR", 2000)); }, k); + auto retina_proj = make_retina(retina.get_uid(), crop, crop, patch, patch, dog_r); + + std::mt19937 rng(static_cast(env_f("KNP_SEED", 1))); + std::uniform_real_distribution init(0.F, env_f("KNP_INIT_W", 30)); + std::vector learn_syn; + for (size_t j = 0; j < k; ++j) + for (size_t i = 0; i < ret_n; ++i) + { + knp::synapse_traits::synapse_parameters s; + s.rule_.w_min_ = w_min; + s.rule_.w_max_ = w_max; + s.rule_.dopamine_plasticity_period_ = 10; + s.weight_ = init(rng); + s.rule_.synaptic_resource_ = resource_from_weight(s.weight_); + s.delay_ = 1; + s.output_type_ = knp::synapse_traits::OutputType::EXCITATORY; + learn_syn.push_back(ResourceProjection::Synapse{s, i, j}); + } + ResourceProjection learn_proj( + retina.get_uid(), detectors.get_uid(), [&](size_t i) { return std::optional(learn_syn[i]); }, + learn_syn.size()); + learn_proj.unlock_weights(); + + // Lateral inhibition and own-spike reward. + const float inhib = env_f("KNP_INHIB", 5000), reward = env_f("KNP_WTA_REWARD", 1); + DeltaProjection lateral( + detectors.get_uid(), detectors.get_uid(), + [&](size_t i) -> std::optional + { + const size_t pre = i / k, post = i % k; + if (pre == post) + return DeltaProjection::Synapse{{reward, 1, knp::synapse_traits::OutputType::DOPAMINE}, pre, post}; + return DeltaProjection::Synapse{{-inhib, 1, knp::synapse_traits::OutputType::EXCITATORY}, pre, post}; + }, + k * k); + + const auto retina_uid = retina_proj.get_uid(), learn_uid = learn_proj.get_uid(), det_uid = detectors.get_uid(); + knp::framework::Network network; + network.add_population(std::move(retina)); + network.add_population(std::move(detectors)); + network.add_projection(std::move(retina_proj)); + network.add_projection(std::move(learn_proj)); + network.add_projection(std::move(lateral)); + knp::core::UID in_channel, out_channel; + knp::framework::Model model(std::move(network)); + model.add_input_channel(in_channel, retina_uid); + model.add_output_channel(out_channel, det_uid); + + std::vector current; + auto input_gen = [&](knp::core::Step step) -> knp::core::messaging::SpikeData + { + const size_t img = step / steps_per_image, local = step % steps_per_image; + if (local == 0) current = raster(px.data() + img * in_n, in_n); + if (local >= active_steps) return {}; + return current[local]; + }; + knp::framework::BackendLoader loader; + auto backend = loader.load(backend_path); + knp::framework::ModelExecutor executor(model, backend, {{in_channel, input_gen}}); + auto &out = executor.get_loader().get_output_channel(out_channel); + const auto t0 = std::chrono::steady_clock::now(); + executor.start([&](size_t step) { return step < count * steps_per_image; }); + std::vector wins(k, 0); + for (const auto &m : out.update()) + for (auto i : m.neuron_indexes_) ++wins[i]; + auto w = trained_weights(backend, learn_uid, ret_n, k); + std::ofstream(out_path, std::ios::binary).write(reinterpret_cast(w.data()), w.size() * 4); + size_t used = 0; + for (auto v : wins) used += v > 0; + std::cout << "features: " << count << " patches, " << k << " detectors, " + << std::chrono::duration(std::chrono::steady_clock::now() - t0).count() << " s; detectors that " + << "fired: " << used << "; spikes in last batch: " << std::accumulate(wins.begin(), wins.end(), 0UL) + << std::endl; + return 0; +} + + +int run_mode(int argc, const char *const argv[]) +{ + const std::string images_path = argv[2], labels_path = argv[3], feat_path = argv[6], backend_path = argv[9], + prefix = argv[10]; + const size_t train = std::stoul(argv[4]), eval = std::stoul(argv[5]), k = std::stoul(argv[7]), + classes = std::stoul(argv[8]); + constexpr int side = 32, in_n = 3 * side * side, ret_side = side, ret_n = maps * side * side, stride = 2; + constexpr int per_side = (side - patch) / stride + 1, positions = per_side * per_side, fan_in = maps * patch * patch; + const size_t det_n = k * positions; + const auto px = read_bin(images_path); + std::vector labels; + { + std::ifstream f(labels_path); + for (size_t v; f >> v;) labels.push_back(v); + } + // Detector weights: learned prototypes minus their mean, scaled to integers in ±KNP_DET_WMAX. + std::vector proto(k * fan_in); + { + std::ifstream f(feat_path, std::ios::binary); + f.read(reinterpret_cast(proto.data()), proto.size() * 4); + } + std::vector mean(fan_in, 0.F); + for (size_t j = 0; j < k; ++j) + for (int i = 0; i < fan_in; ++i) mean[i] += proto[j * fan_in + i] / k; + float max_abs = 1e-6F; + for (size_t j = 0; j < k; ++j) + for (int i = 0; i < fan_in; ++i) max_abs = std::max(max_abs, std::fabs(proto[j * fan_in + i] - mean[i])); + const float det_wmax = env_f("KNP_DET_WMAX", 127); + std::vector det_w(k * fan_in); + for (size_t j = 0; j < k; ++j) + for (int i = 0; i < fan_in; ++i) + det_w[j * fan_in + i] = std::round((proto[j * fan_in + i] - mean[i]) / max_abs * det_wmax); + + const float out_thr = env_f("KNP_OUT_THR", 4000); + auto build = [&](bool learning, const std::vector &readout_w, knp::core::UID &image_proj_uid, + knp::core::UID &label_proj_uid, knp::core::UID &readout_proj_uid, knp::core::UID &out_uid) + { + AltAIPopulation retina([&](size_t) { return plain_neuron(env_f("KNP_RETINA_THR", 250)); }, ret_n); + AltAIPopulation detectors([&](size_t) { return plain_neuron(env_f("KNP_DET_THR", 2000)); }, det_n); + auto retina_proj = make_retina(retina.get_uid(), side, side, ret_side, ret_side, 0); + // Detector index = proto * positions + position; synapse index = detector * fan_in + element. + DeltaProjection det_proj( + retina.get_uid(), detectors.get_uid(), + [&](size_t index) -> std::optional + { + const size_t neuron = index / fan_in, e = index % fan_in; + const size_t j = neuron / positions, pos = neuron % positions; + const int py = static_cast(pos / per_side) * stride, pxx = static_cast(pos % per_side) * stride; + const int m = static_cast(e / (patch * patch)), dy = static_cast(e % (patch * patch)) / patch, + dx = static_cast(e % patch); + const float weight = det_w[j * fan_in + e]; + if (weight == 0) return std::nullopt; + return DeltaProjection::Synapse{ + {weight, 1, knp::synapse_traits::OutputType::EXCITATORY}, + static_cast((m * ret_side + py + dy) * ret_side + pxx + dx), neuron}; + }, + det_n * fan_in); + image_proj_uid = retina_proj.get_uid(); + knp::framework::Network network; + if (learning) + { + StdpPopulation readout([&](size_t) { return learning_neuron(out_thr); }, classes); + ResourceProjection rproj( + detectors.get_uid(), readout.get_uid(), + [&](size_t i) -> std::optional + { + knp::synapse_traits::synapse_parameters s; + s.rule_.w_min_ = w_min; + s.rule_.w_max_ = w_max; + s.rule_.dopamine_plasticity_period_ = 10; + s.weight_ = 0; + s.rule_.synaptic_resource_ = resource_from_weight(0); + s.delay_ = 1; + s.output_type_ = knp::synapse_traits::OutputType::EXCITATORY; + return ResourceProjection::Synapse{s, i % det_n, i / det_n}; + }, + det_n * classes); + rproj.unlock_weights(); + const auto delta = env_list("KNP_DELTA", {1, 4}); + DeltaProjection label_proj( + knp::core::UID{false}, readout.get_uid(), + [&](size_t i) { + return std::optional(DeltaProjection::Synapse{ + {delta[0], 1, knp::synapse_traits::OutputType::DOPAMINE}, i, i}); + }, + classes); + DeltaProjection self_proj( + readout.get_uid(), readout.get_uid(), + [&](size_t i) { + return std::optional(DeltaProjection::Synapse{ + {-delta[1], 1, knp::synapse_traits::OutputType::DOPAMINE}, i, i}); + }, + classes); + label_proj_uid = label_proj.get_uid(); + readout_proj_uid = rproj.get_uid(); + out_uid = readout.get_uid(); + network.add_population(std::move(readout)); + network.add_projection(std::move(rproj)); + network.add_projection(std::move(label_proj)); + network.add_projection(std::move(self_proj)); + } + else + { + AltAIPopulation readout([&](size_t) { return plain_neuron(out_thr); }, classes); + DeltaProjection rproj( + detectors.get_uid(), readout.get_uid(), + [&](size_t i) -> std::optional + { + const float weight = readout_w[(i / det_n) * det_n + i % det_n]; + if (weight == 0) return std::nullopt; + return DeltaProjection::Synapse{ + {weight, 1, knp::synapse_traits::OutputType::EXCITATORY}, i % det_n, i / det_n}; + }, + det_n * classes); + out_uid = readout.get_uid(); + network.add_population(std::move(readout)); + network.add_projection(std::move(rproj)); + } + network.add_population(std::move(retina)); + network.add_population(std::move(detectors)); + network.add_projection(std::move(retina_proj)); + network.add_projection(std::move(det_proj)); + return network; + }; + + std::vector current; + auto image_gen = [&](size_t first) + { + return [&, first](knp::core::Step step) -> knp::core::messaging::SpikeData + { + const size_t img = first + step / steps_per_image, local = step % steps_per_image; + if (local == 0) current = raster(px.data() + img * in_n, in_n); + if (local >= active_steps) return {}; + return current[local]; + }; + }; + knp::framework::BackendLoader loader; + std::vector readout_w(det_n * classes, 0.F); + if (train > 0) + { + knp::core::UID image_proj, label_proj, readout_proj, out_uid; + knp::framework::Model model(build(true, readout_w, image_proj, label_proj, readout_proj, out_uid)); + knp::core::UID in_channel, label_channel; + model.add_input_channel(in_channel, image_proj); + model.add_input_channel(label_channel, label_proj); + auto label_gen = [&](knp::core::Step step) -> knp::core::messaging::SpikeData + { + if (step % steps_per_image >= active_steps) return {}; + return {static_cast(labels[step / steps_per_image])}; + }; + auto backend = loader.load(backend_path); + knp::framework::ModelExecutor executor(model, backend, {{in_channel, image_gen(0)}, {label_channel, label_gen}}); + const auto t0 = std::chrono::steady_clock::now(); + executor.start( + [&](size_t step) + { + if (step % (1000 * steps_per_image) == 0 && step) + std::cout << "trained " << step / steps_per_image << " images, " + << std::chrono::duration(std::chrono::steady_clock::now() - t0).count() << " s" + << std::endl; + return step < train * steps_per_image; + }); + readout_w = trained_weights(backend, readout_proj, det_n, classes); + std::ofstream(prefix + "_readout.bin", std::ios::binary) + .write(reinterpret_cast(readout_w.data()), readout_w.size() * 4); + std::cout << "training done in " << std::chrono::duration(std::chrono::steady_clock::now() - t0).count() + << " s" << std::endl; + } + else + { + std::ifstream f(prefix + "_readout.bin", std::ios::binary); + f.read(reinterpret_cast(readout_w.data()), readout_w.size() * 4); + } + if (eval == 0) return 0; + knp::core::UID image_proj, label_proj, readout_proj, out_uid; + knp::framework::Model model(build(false, readout_w, image_proj, label_proj, readout_proj, out_uid)); + knp::core::UID in_channel, out_channel; + model.add_input_channel(in_channel, image_proj); + model.add_output_channel(out_channel, out_uid); + // Evaluation starts right after the training images unless KNP_EVAL_FIRST is given. + const size_t first = std::getenv("KNP_EVAL_FIRST") ? static_cast(env_f("KNP_EVAL_FIRST", 0)) : train; + auto backend = loader.load(backend_path); + knp::framework::ModelExecutor executor(model, backend, {{in_channel, image_gen(first)}}); + auto &out = executor.get_loader().get_output_channel(out_channel); + const auto t0 = std::chrono::steady_clock::now(); + executor.start([&](size_t step) { return step < eval * steps_per_image; }); + std::vector> counts(eval, std::vector(classes, 0)); + for (const auto &m : out.update()) + { + // A spike sent on step s belongs to image s / steps_per_image (output arrives within the same image window). + const size_t img = m.header_.send_time_ / steps_per_image; + if (img < eval) + for (auto i : m.neuron_indexes_) ++counts[img][i]; + } + std::ofstream csv(prefix + "_eval.csv"); + csv << "index,label"; + for (size_t c = 0; c < classes; ++c) csv << ",c" << c; + csv << "\n"; + size_t correct = 0, silent = 0; + for (size_t i = 0; i < eval; ++i) + { + const auto &c = counts[i]; + const int best = static_cast(std::max_element(c.begin(), c.end()) - c.begin()); + const bool none = *std::max_element(c.begin(), c.end()) == 0; + silent += none; + correct += !none && static_cast(best) == labels[first + i]; + csv << first + i << ',' << labels[first + i]; + for (auto v : c) csv << ',' << v; + csv << "\n"; + } + std::cout << "eval: " << correct << " / " << eval << " correct, " << silent << " silent, " + << std::chrono::duration(std::chrono::steady_clock::now() - t0).count() << " s" << std::endl; + return 0; +} + + +int main(int argc, const char *const argv[]) +{ + const std::string mode = argc > 1 ? argv[1] : ""; + if (mode == "features" && argc == 7) return features_mode(argc, argv); + if (mode == "run" && argc == 11) return run_mode(argc, argv); + std::cerr << "usage: see the header of altai_objects.cpp" << std::endl; + return 1; +} diff --git a/altai-objects/bench_conv.cpp b/altai-objects/bench_conv.cpp new file mode 100644 index 0000000..4ce7892 --- /dev/null +++ b/altai-objects/bench_conv.cpp @@ -0,0 +1,92 @@ +// Speed check: a convolution-like feature layer built from AltAI neurons in KNP. +// usage: bench_conv +// K prototypes × positions (6×6×3 patches of a 32×32×3 image with the given stride) AltAI neurons; random integer +// weights; random input spikes (pixel rate ~0.4 per active step); 15 steps per image, 10 of them active. + +#include +#include +#include +#include + +#include +#include +#include +#include + + +using DeltaProjection = knp::core::Projection; +using AltAIPopulation = knp::core::Population; + +constexpr int side = 32, patch = 6, channels = 3, steps_per_image = 15, active_steps = 10; + + +int main(int argc, const char *const argv[]) +{ + const int k = std::stoi(argv[1]), stride = std::stoi(argv[2]), images = std::stoi(argv[3]); + const int per_side = (side - patch) / stride + 1, positions = per_side * per_side; + const size_t neurons = static_cast(k) * positions, fan_in = patch * patch * channels; + + AltAIPopulation population( + [](size_t) + { + knp::neuron_traits::neuron_parameters p; + p.activation_threshold_ = 1000; + p.negative_activation_threshold_ = 0; + p.potential_reset_value_ = 0; + return p; + }, + neurons); + + std::mt19937 rng(1); + std::uniform_int_distribution weight(-60, 60); + std::vector w(fan_in * k); + for (auto &v : w) v = weight(rng); + + // Synapse index → (neuron, patch element); the source is the input channel index of that pixel. + DeltaProjection input_projection( + knp::core::UID{false}, population.get_uid(), + [&](size_t index) -> std::optional + { + const size_t neuron = index / fan_in, e = index % fan_in; + const int proto = static_cast(neuron / positions), pos = static_cast(neuron % positions); + const int py = (pos / per_side) * stride, px = (pos % per_side) * stride; + const int c = static_cast(e / (patch * patch)), dy = static_cast(e % (patch * patch)) / patch, + dx = static_cast(e % patch); + const size_t source = static_cast(c * side * side + (py + dy) * side + px + dx); + return DeltaProjection::Synapse{ + {static_cast(w[proto * fan_in + e]), 1, knp::synapse_traits::OutputType::EXCITATORY}, source, + neuron}; + }, + neurons * fan_in); + + const knp::core::UID input_uid = input_projection.get_uid(), output_uid = population.get_uid(); + knp::framework::Network network; + network.add_population(std::move(population)); + network.add_projection(std::move(input_projection)); + knp::core::UID in_channel, out_channel; + knp::framework::Model model(std::move(network)); + model.add_input_channel(in_channel, input_uid); + model.add_output_channel(out_channel, output_uid); + + std::bernoulli_distribution fire(0.4); + auto input_gen = [&](knp::core::Step step) -> knp::core::messaging::SpikeData + { + knp::core::messaging::SpikeData s; + if (step % steps_per_image >= active_steps) return s; + for (size_t i = 0; i < side * side * channels; ++i) + if (fire(rng)) s.push_back(i); + return s; + }; + + knp::framework::BackendLoader loader; + knp::framework::ModelExecutor executor(model, loader.load(argv[4]), {{in_channel, input_gen}}); + auto &out = executor.get_loader().get_output_channel(out_channel); + const auto t0 = std::chrono::steady_clock::now(); + executor.start([&](size_t step) { return step < static_cast(images * steps_per_image); }); + const double sec = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + size_t spikes = 0; + for (const auto &m : out.update()) spikes += m.neuron_indexes_.size(); + std::cout << "K=" << k << " stride=" << stride << " neurons=" << neurons << " synapses=" << neurons * fan_in + << " images=" << images << " seconds=" << sec << " per_image=" << sec / images + << " output_spikes_per_image=" << spikes / images << std::endl; +} diff --git a/altai-objects/build.py b/altai-objects/build.py new file mode 100644 index 0000000..9f318b4 --- /dev/null +++ b/altai-objects/build.py @@ -0,0 +1,36 @@ +"""Build a standalone KNP program from one .cpp using the working mnist-learn compile and link settings. + +usage: python3 build.py [more.cpp ...] → /home/int/run/altai-objects/ +""" +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +import shlex +import subprocess +import sys + +build = Path('/home/int/run/altai-objects') +build.mkdir(parents=True, exist_ok=True) +old = Path('/home/int/knp-build/examples/mnist-learn') +flags = {} +for line in (old / 'CMakeFiles/mnist_learn_example.dir/flags.make').read_text().splitlines(): + if ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +link = shlex.split((old / 'CMakeFiles/mnist_learn_example.dir/link.txt').read_text()) +sources = [Path(s).resolve() for s in sys.argv[1:]] + + +def compile_file(src): + target = build / (src.stem + '.o') + subprocess.run(['/usr/bin/c++', '-std=c++17', *flags['CXX_DEFINES'], '-I' + str(src.parent), *flags['CXX_INCLUDES'], + *flags['CXX_FLAGS'], '-Wno-error', '-c', str(src), '-o', str(target)], check=True) + return str(target) + + +with ThreadPoolExecutor(max_workers=4) as pool: + objects = list(pool.map(compile_file, sources)) +tail = link[link.index('-o') + 2:] +tail = [str((old / x).resolve()) if x.startswith('../../') else x for x in tail] +out = build / sources[0].stem +subprocess.run(['/usr/bin/c++', *objects, '-o', str(out), *tail], cwd=old, check=True) +print(out, flush=True) diff --git a/altai-objects/export_knp.py b/altai-objects/export_knp.py new file mode 100644 index 0000000..3717101 --- /dev/null +++ b/altai-objects/export_knp.py @@ -0,0 +1,23 @@ +"""Export vehicles data for altai_objects: train+val images (uint8 3×32×32, train first), labels, random 12×12 crops. + +usage: ~/anvenv/bin/python export_knp.py [--crops 200000] → /home/int/run/cifar/vehicles/knp_{images.bin,labels.txt,crops.bin} +""" +import argparse + +import numpy as np + +ap = argparse.ArgumentParser() +ap.add_argument("--crops", type=int, default=200000) +a = ap.parse_args() +d = "/home/int/run/cifar/vehicles" +tr, va = np.load(f"{d}/train.npz"), np.load(f"{d}/val.npz") +xt, xv = tr["rgb"], va["rgb"] # load once: indexing an npz entry re-reads the whole array +x = np.concatenate([xt, xv]).astype(np.uint8) +y = np.concatenate([tr["labels"], va["labels"]]) +x.tofile(f"{d}/knp_images.bin") +open(f"{d}/knp_labels.txt", "w").write("\n".join(map(str, y)) + "\n") +rng = np.random.default_rng(7) +i, oy, ox = rng.integers(0, len(xt), a.crops), rng.integers(0, 21, a.crops), rng.integers(0, 21, a.crops) +crops = np.stack([xt[j, :, b:b + 12, c:c + 12] for j, b, c in zip(i, oy, ox)]).astype(np.uint8) +crops.tofile(f"{d}/knp_crops.bin") +print("images", x.shape, "crops", crops.shape) diff --git a/altai-objects/feature_grid.sh b/altai-objects/feature_grid.sh new file mode 100644 index 0000000..6fbe34c --- /dev/null +++ b/altai-objects/feature_grid.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Tuning grid for KNP feature learning (K=64, 20000 crops); each line: name rate decay det_thr inhib +cd ~/run/altai-objects/fgrid +export LD_LIBRARY_PATH=$HOME/knp-build/bin:$HOME/deps/lib +one() { n=$1; [ -f $n.score ] && return + env KNP_DELTA_RULE=$2 KNP_DELTA_DECAY=$3 KNP_DELTA_LAG=1 KNP_DET_THR=$4 KNP_INHIB=$5 \ + ../altai_objects features /home/int/run/cifar/vehicles/knp_crops.bin ${NPATCH:-20000} 64 $n.bin \ + $HOME/run/delta-cpu/libknp-cpu-delta-backend.so 2>&1 | grep "^features" > $n.log + ~/anvenv/bin/python /mnt/c/knp/altai-objects/score_features.py $n.bin 64 > $n.score 2>&1; } +export -f one +cat ${SPECFILE:-/dev/stdin} | xargs -P ${JOBS:-12} -L 1 bash -c 'one "$@"' _ diff --git a/altai-objects/prepare_vehicles.py b/altai-objects/prepare_vehicles.py new file mode 100644 index 0000000..6563815 --- /dev/null +++ b/altai-objects/prepare_vehicles.py @@ -0,0 +1,41 @@ +"""CIFAR-10 vehicles (airplane, automobile, ship, truck → 0..3) from the Hugging Face parquet copy (uoft-cs/cifar10). + +train: first 4000 training images of each class (16000), val: the last 1000 of each class (4000), +test: the official test images of these classes (4000) — kept for one final evaluation. +usage: ~/anvenv/bin/python prepare_vehicles.py → /home/int/run/cifar/vehicles/{train,val,test}.npz +""" +import io +from pathlib import Path + +import numpy as np +import pyarrow.parquet as pq +from PIL import Image + +src, out = Path('/home/int/run/cifar'), Path('/home/int/run/cifar/vehicles') +out.mkdir(exist_ok=True) +CLASSES = {0: 0, 1: 1, 8: 2, 9: 3} # CIFAR label → our label +NAMES = ['airplane', 'automobile', 'ship', 'truck'] + + +def load(name): + t = pq.read_table(src / f'hf_{name}.parquet').to_pydict() + imgs, labels = [], [] + for img, lab in zip(t['img'], t['label']): + if lab in CLASSES: + imgs.append(np.asarray(Image.open(io.BytesIO(img['bytes'])).convert('RGB')).transpose(2, 0, 1)) + labels.append(CLASSES[lab]) + return np.stack(imgs), np.array(labels, np.uint8) + + +x, y = load('train') +idx = {c: np.flatnonzero(y == c) for c in range(4)} +train = np.concatenate([idx[c][:4000] for c in range(4)]) +val = np.concatenate([idx[c][4000:] for c in range(4)]) +rng = np.random.default_rng(20261015) +train, val = rng.permutation(train), rng.permutation(val) +np.savez_compressed(out / 'train.npz', rgb=x[train], labels=y[train]) +np.savez_compressed(out / 'val.npz', rgb=x[val], labels=y[val]) +xt, yt = load('test') +p = rng.permutation(len(yt)) +np.savez_compressed(out / 'test.npz', rgb=xt[p], labels=yt[p]) +print('train', len(train), np.bincount(y[train]), 'val', len(val), 'test', len(yt), np.bincount(yt), NAMES) diff --git a/altai-objects/retina_proto.py b/altai-objects/retina_proto.py new file mode 100644 index 0000000..a6c76f6 --- /dev/null +++ b/altai-objects/retina_proto.py @@ -0,0 +1,113 @@ +"""Design check for the all-on-platform pipeline (numpy prototype, not the final result). + +Retina: per colour channel a centre–surround (difference of Gaussians) filter; ON = max(0, DoG), OFF = max(0, −DoG) +→ 6 maps 32×32 (fixed weights, i.e. one fixed layer of neurons). Features: online winner-take-all on 6×6×6 retina +patches (unit-norm weights, winner = largest w·x, winner moves towards the patch), activation max(0, w·x − θ) with +θ a quantile of the winners' responses; sum pooling over quadrants; linear readout (ridge here, delta rule in KNP). + +usage: ~/anvenv/bin/python retina_proto.py [--k 200 --stride 2] +""" +import argparse + +import numpy as np + +PATCH = 6 + + +def gauss(sigma, r=3): + y, x = np.mgrid[-r:r + 1, -r:r + 1] + g = np.exp(-(x * x + y * y) / (2 * sigma * sigma)) + return g / g.sum() + + +DOG = gauss(0.8) - gauss(2.0) + + +def retina(rgb, gain=4.0): + """rgb uint8 [n,3,32,32] → float32 [n,6,32,32] in [0,1]: ON and OFF maps per colour.""" + x = rgb.astype(np.float32) / 255 + r = DOG.shape[0] // 2 + xp = np.pad(x, ((0, 0), (0, 0), (r, r), (r, r)), mode="edge") + d = np.zeros_like(x) + for dy in range(DOG.shape[0]): + for dx in range(DOG.shape[1]): + d += DOG[dy, dx] * xp[:, :, dy:dy + 32, dx:dx + 32] + return np.clip(np.concatenate([np.maximum(d, 0), np.maximum(-d, 0)], axis=1) * gain, 0, 1) + + +def patches(maps, stride): + pos = range(0, 32 - PATCH + 1, stride) + return np.stack([maps[:, :, y:y + PATCH, x:x + PATCH].reshape(len(maps), -1) for y in pos for x in pos], axis=1) + + +def learn_wta(maps, k, n=300000, lr0=0.05, seed=0): + rng = np.random.default_rng(seed) + i, y, x = rng.integers(0, len(maps), n), rng.integers(0, 27, n), rng.integers(0, 27, n) + p = np.stack([maps[a, :, b:b + PATCH, c:c + PATCH].reshape(-1) for a, b, c in zip(i, y, x)]) + p = p[p.sum(1) > 0.5] # skip empty patches (no retina activity) + w = p[rng.choice(len(p), k, replace=False)].copy() + w /= np.linalg.norm(w, axis=1, keepdims=True) + for t, v in enumerate(p): + win = int(np.argmax(w @ v)) + w[win] += lr0 / (1 + t / 20000) * (v / (np.linalg.norm(v) + 1e-6) - w[win]) + w[win] /= np.linalg.norm(w[win]) + return w, p + + +def encode(maps, w, theta, stride, batch=500, centre=False): + """centre=True: subtract the mean prototype (activation max(0, (w_k − mean_j w_j)·x − θ)); still linear.""" + if centre: + w = w - w.mean(0) + out = [] + for s in range(0, len(maps), batch): + p = patches(maps[s:s + batch], stride) + a = np.maximum(p @ w.T - theta, 0) + side = int(np.sqrt(p.shape[1])) + a = a.reshape(len(p), side, side, -1) + h = side // 2 + out.append(np.concatenate([a[:, :h, :h].sum((1, 2)), a[:, :h, h:].sum((1, 2)), + a[:, h:, :h].sum((1, 2)), a[:, h:, h:].sum((1, 2))], axis=1)) + return np.concatenate(out) + + +def ridge_acc(ft, yt, fv, yv, lam, classes): + mu, sd = ft.mean(0), ft.std(0) + 1e-3 + f = lambda z: np.hstack([(z - mu) / sd, np.ones((len(z), 1), np.float32)]) + w = np.linalg.solve(f(ft).T @ f(ft) + lam * np.eye(ft.shape[1] + 1), f(ft).T @ (np.eye(classes)[yt] * 2 - 1)) + return 100 * ((f(fv) @ w).argmax(1) == yv).mean() + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("data") + ap.add_argument("--k", type=int, default=200) + ap.add_argument("--stride", type=int, default=2) + ap.add_argument("--flip", action="store_true") + ap.add_argument("--centre", action="store_true") + ap.add_argument("--sigmas", default="0.8,2.0") + ap.add_argument("--gain", type=float, default=4.0) + a = ap.parse_args() + global DOG + s1, s2 = (float(v) for v in a.sigmas.split(",")) + DOG = gauss(s1) - gauss(s2) + tr, va = np.load(f"{a.data}/train.npz"), np.load(f"{a.data}/val.npz") + xt, yt, xv, yv = tr["rgb"], tr["labels"].astype(int), va["rgb"], va["labels"].astype(int) + classes = int(yt.max()) + 1 + mt, mv = retina(xt, a.gain), retina(xv, a.gain) + w, p = learn_wta(mt, a.k) + wc = w - w.mean(0) if a.centre else w + resp = (p[:20000] @ wc.T).max(1) + for q in ((0, 50) if a.centre else (50, 75)): + theta = float(np.percentile(resp, q)) if q else 0.0 + ft, fv = encode(mt, w, theta, a.stride, centre=a.centre), encode(mv, w, theta, a.stride, centre=a.centre) + if a.flip: + ft = np.vstack([ft, encode(retina(xt[:, :, :, ::-1], a.gain), w, theta, a.stride, centre=a.centre)]) + ytt = np.r_[yt, yt] + else: + ytt = yt + print(f"K {a.k} stride {a.stride} theta=q{q} ({theta:.3f}): " + + " | ".join(f"lam {lam}: {ridge_acc(ft, ytt, fv, yv, lam, classes):.1f}%" for lam in (100, 1000)), flush=True) + + +if __name__ == "__main__": + main() diff --git a/altai-objects/score_features.py b/altai-objects/score_features.py new file mode 100644 index 0000000..ab08444 --- /dev/null +++ b/altai-objects/score_features.py @@ -0,0 +1,30 @@ +"""Quick quality score of KNP-learned feature detectors (tuning aid only; final numbers come from KNP). + +Takes float32 weights [K][216] written by `altai_objects features`, encodes vehicles train (first N) and validation +with the numpy retina prototype (centred weights, threshold 0, quadrant pooling) and reports ridge accuracy. +usage: ~/anvenv/bin/python score_features.py [--train 6000] +""" +import argparse +import sys + +import numpy as np + +sys.path.insert(0, "/mnt/c/knp/altai-objects") +import retina_proto as rp # noqa: E402 + +ap = argparse.ArgumentParser() +ap.add_argument("weights") +ap.add_argument("k", type=int) +ap.add_argument("--train", type=int, default=6000) +a = ap.parse_args() +rp.DOG = rp.gauss(0.5) - rp.gauss(1.5) +w = np.fromfile(a.weights, np.float32).reshape(a.k, -1) +d = "/home/int/run/cifar/vehicles" +tr, va = np.load(f"{d}/train.npz"), np.load(f"{d}/val.npz") +xt, yt = tr["rgb"][:a.train], tr["labels"][:a.train].astype(int) +xv, yv = va["rgb"], va["labels"].astype(int) +ft = rp.encode(rp.retina(xt), w, 0.0, 2, centre=True) +fv = rp.encode(rp.retina(xv), w, 0.0, 2, centre=True) +dead = int((ft.reshape(len(ft), 4, -1).sum((0, 1)) == 0).sum()) +print(f"{a.weights}: ridge validation " + " | ".join( + f"lam {lam}: {rp.ridge_acc(ft, yt, fv, yv, lam, 4):.1f}%" for lam in (100, 1000)) + f"; silent detectors {dead}/{a.k}") diff --git a/changes-2026-10-15.md b/changes-2026-10-15.md index dd61723..e550d90 100644 --- a/changes-2026-10-15.md +++ b/changes-2026-10-15.md @@ -52,3 +52,14 @@ MNIST, признаки coarse, 30 000 обучение, 10 000 проверка - Три прохода по обучению (обучающая часть ×3 с новым перемешиванием, `*_x3_validation`, 54 000): K=512 — r1 b4 67,3%, r1 b8 65,0%, r3 b4 66,4%; K=1600 r1 b8 63,2%. Не помогает. - Вывод по объектам: в KNP дофамин по ошибке даёт 65–69% (колонки STDP — 64–65% на разреженных, ≈0% на плотных); ridge на тех же признаках — 74%. Вероятная причина разрыва — ограниченный и несимметричный диапазон весов KNP (−253…+92) и насыщение ресурса. - `KNP_W_RANGE=,` (MNIST и cats-dogs `construct_network.cpp`): диапазон обучаемых весов. Кошки/собаки r3 b4: ±120 — 66,1%, ±250 — 66,4% (r1 b8 — 61,8%), ±500 — 64,9% (по умолчанию −253…+92 — 69,1%). Гипотеза про диапазон не подтвердилась. Разброс весов большой (σ 91 при стандартном диапазоне, 177 при ±250); вероятнее, мешает онлайн-обучение за один проход с крупными шагами. + +## Утро: всё на платформе — распознавание транспорта (цель — демо для беспилотников) + +Решения пользователя: живое демо; тезис «нейроморфный Алтай заменяет нейросеть для распознавания объектов в беспилотных системах»; всё (и признаки, и обучение) должно работать в KNP. + +- Скорость KNP на свёрточном слое (`altai-objects/bench_conv.cpp`, однопоточный бэкенд, случайный вход 0,4): K=32 шаг 2 — 0,06 с/картинку, 0,2 ГБ; K=100 шаг 2 — 0,25 с, 0,7 ГБ; K=200 шаг 2 — 0,95 с, 1,4 ГБ; K=100 шаг 1 — 1,8 с, 2,6 ГБ (~320 байт на синапс). +- Данные: CIFAR-10 с Hugging Face (`uoft-cs/cifar10`, parquet; официальный сервер давал ~37 КБ/с). `altai-objects/prepare_vehicles.py` → `~/run/cifar/vehicles`: самолёт/автомобиль/корабль/грузовик; train 16 000 (первые 4000 каждого класса), val 4000 (последние 1000), test 4000 (официальный, не просмотрен). pyarrow установлен в `~/anvenv`. +- Проверка замысла в numpy (`altai-objects/retina_proto.py`): сетчатка ON/OFF (DoG σ 0,5/1,5, усиление 4) → онлайн WTA на кусочках 6×6×6 → вычитание среднего узора (линейно) → пулинг по четвертям → ridge. Кошки/собаки v3, K=200 шаг 2: 70,2% (с нормировкой и ZCA при том же размере — 73,6%). Транспорт: **76,0%**. +- `altai-objects/altai_objects.cpp` (сборка `altai-objects/build.py`): режим `features` — кусочки 12×12 → сетчатка (216 нейронов AltAI, фиксированные веса DoG×1000) → K детекторов STDP-AltAI, взаимное торможение и дофамин от собственного спайка; режим `run` — полная сеть пиксели → сетчатка 6×32×32 → детекторы K×196 (выученные веса минус средний, целые) → выход (дофамин по ошибке), затем веса читаются из бэкенда и фиксируются для проверки. +- Бэкенд: `KNP_DELTA_DECAY=d` — у нейрона с дофамином D неактивные синапсы меняются на −скорость·D·d (соревновательное обучение). +- `altai-objects/export_knp.py` (ошибка: многократное чтение npz съело 31 ГБ — исправлено), `score_features.py` — быстрая оценка узоров (только для настройки). Ориентиры (K=64, 6000 обучение): узоры WTA в numpy 68,0%, случайные веса 62,2%; первая проба KNP (5000 кусочков, убавка 0,2) — 60,9%. Сетчатка: в среднем 10% входов спайкают за шаг. diff --git a/readout/build_delta_cpu.py b/readout/build_delta_cpu.py index c97fc74..c1af385 100644 --- a/readout/build_delta_cpu.py +++ b/readout/build_delta_cpu.py @@ -1,8 +1,9 @@ """Build an isolated CPU single-threaded backend with an error-driven (delta-rule) dopamine plasticity option. KNP_DELTA_RULE= (environment, read once): for every AltAI STDP neuron with non-zero dopamine D on a step, -each trainable synapse whose presynaptic spike arrives on that step changes its synaptic resource by rate * D, -whether or not the neuron itself spiked; stability is not used. Without the variable the backend behaves like +each trainable synapse whose presynaptic spike arrived KNP_DELTA_LAG steps earlier changes its synaptic resource by +rate * D, whether or not the neuron itself spiked; stability is not used. KNP_DELTA_DECAY= additionally changes +every other synapse of that neuron by -rate * D * d (competitive learning). Without the variable the backend behaves like the stock one. Original sources and libraries are not modified. usage: python3 build_delta_cpu.py → /home/int/run/delta-cpu/libknp-cpu-delta-backend.so @@ -30,6 +31,13 @@ s = s.replace(marker, ''' // Experimental delta rule (KNP_DELTA_RULE=), const char *value = std::getenv("KNP_DELTA_LAG"); return value ? std::stol(value) : 0L; }(); + // Competitive (winner-take-all) learning: with KNP_DELTA_DECAY=, synapses whose input did not arrive change by + // -rate * D * d, so a rewarded neuron moves its weights towards the current input pattern. + static const float delta_decay = [] + { + const char *value = std::getenv("KNP_DELTA_DECAY"); + return value ? std::stof(value) : 0.F; + }(); static const float delta_rate = [] { const char *value = std::getenv("KNP_DELTA_RULE"); @@ -53,6 +61,8 @@ s = s.replace(marker, ''' // Experimental delta rule (KNP_DELTA_RULE=), // The presynaptic spike reaches the neuron on this step. if (lag == DELTA_LAG) synapse.get().rule_.synaptic_resource_ += delta_rate * neuron.dopamine_value_; + else if (delta_decay > 0) + synapse.get().rule_.synaptic_resource_ -= delta_rate * neuron.dopamine_value_ * delta_decay; } if (debug && ++calls % 500 == 0) std::fprintf(stderr, "KNP_DELTA_DEBUG calls=%lu lag-3..3: %lu %lu %lu %lu %lu %lu %lu\\n",