Upload 3 files
Browse files
Appendix - Export Pytorch to ONNX.ipynb
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
OModeling-2.py
ADDED
|
@@ -0,0 +1,1894 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import gc
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
import torch
|
| 6 |
+
import mlflow
|
| 7 |
+
import logging
|
| 8 |
+
import platform
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
from PIL import Image
|
| 12 |
+
from tqdm import tqdm
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
import torch.optim as optim
|
| 15 |
+
from torchvision import models
|
| 16 |
+
import matplotlib.pyplot as plt
|
| 17 |
+
import torch.nn.functional as F
|
| 18 |
+
from sklearn.manifold import TSNE
|
| 19 |
+
from torchvision import transforms
|
| 20 |
+
from kymatio.torch import Scattering2D
|
| 21 |
+
from torch.utils.data import Dataset, DataLoader
|
| 22 |
+
from pytorch_metric_learning.miners import BatchHardMiner
|
| 23 |
+
from pytorch_metric_learning.losses import MultiSimilarityLoss
|
| 24 |
+
from torch.optim.lr_scheduler import CosineAnnealingLR, ReduceLROnPlateau
|
| 25 |
+
from sklearn.metrics import roc_curve, auc, precision_recall_fscore_support
|
| 26 |
+
from typing import Dict, List, Tuple, Optional, Union, Any
|
| 27 |
+
from dataclasses import dataclass, asdict
|
| 28 |
+
import warnings
|
| 29 |
+
warnings.filterwarnings('ignore')
|
| 30 |
+
|
| 31 |
+
# ----------------------------
|
| 32 |
+
# Configuration Management
|
| 33 |
+
# ----------------------------
|
| 34 |
+
@dataclass
|
| 35 |
+
@dataclass
|
| 36 |
+
class TrainingConfig:
|
| 37 |
+
|
| 38 |
+
# Model Architecture
|
| 39 |
+
model_name: str = "resnet34"
|
| 40 |
+
embedding_dim: int = 128
|
| 41 |
+
normalize_embeddings: bool = True
|
| 42 |
+
pretrained_path: Optional[str] = "../../model/pretrained_model/ResNet34.pt"
|
| 43 |
+
|
| 44 |
+
# Training Hyperparameters
|
| 45 |
+
batch_size: int = 512
|
| 46 |
+
max_epochs: int = 20
|
| 47 |
+
grad_accum_steps: int = 10
|
| 48 |
+
device: str = "cuda" if torch.cuda.is_available() else "cpu"
|
| 49 |
+
|
| 50 |
+
# Learning Rate Configuration
|
| 51 |
+
head_lr: float = 1e-3 # Higher LR for embedding head (untrained)
|
| 52 |
+
backbone_lr: float = 1e-4 # Lower LR for backbone (pretrained)
|
| 53 |
+
lr_scheduler: str = "cosine" # "cosine" or "plateau"
|
| 54 |
+
weight_decay: float = 1e-4
|
| 55 |
+
|
| 56 |
+
# Curriculum Learning Parameters - ADJUSTED FOR PRECISION
|
| 57 |
+
curriculum_strategy: str = "progressive" # "progressive", "exponential", "linear"
|
| 58 |
+
initial_hard_ratio: float = 0.6 # Increased from 0.1 for more hard negatives early
|
| 59 |
+
final_hard_ratio: float = 0.9 # Increased from 0.8 for focus on hard cases
|
| 60 |
+
curriculum_warmup_epochs: int = 1 # Reduced from 2 for faster hard sample exposure
|
| 61 |
+
|
| 62 |
+
# Data Augmentation
|
| 63 |
+
remove_bg: bool = False
|
| 64 |
+
augmentation_strength: float = 0.5 # 0.0 = no aug, 1.0 = strong aug
|
| 65 |
+
|
| 66 |
+
# Loss Configuration - ADJUSTED FOR PRECISION
|
| 67 |
+
multisim_alpha: float = 2.5 # Increased from 2.0 (penalize false positives more)
|
| 68 |
+
multisim_beta: float = 60.0 # Increased from 50.0 (larger margin)
|
| 69 |
+
multisim_base: float = 0.4 # Decreased from 0.5 (stricter similarity)
|
| 70 |
+
|
| 71 |
+
# Triplet Loss Parameters - NEW
|
| 72 |
+
triplet_margin: float = 1.0 # Margin for triplet loss
|
| 73 |
+
triplet_weight: float = 0.3 # Weight for triplet loss component
|
| 74 |
+
false_positive_penalty_weight: float = 0.3 # Extra penalty for false positives
|
| 75 |
+
|
| 76 |
+
# Mining Configuration
|
| 77 |
+
use_hard_mining: bool = True
|
| 78 |
+
|
| 79 |
+
# Precision Focus Parameters - NEW
|
| 80 |
+
target_precision: float = 0.75 # Target precision for threshold selection
|
| 81 |
+
negative_weight_multiplier: float = 2.5 # How much more to weight hard negatives
|
| 82 |
+
|
| 83 |
+
# Checkpoint Configuration
|
| 84 |
+
run_id: Optional[str] = None
|
| 85 |
+
last_epoch_weights: Optional[str] = None
|
| 86 |
+
save_frequency: int = 1 # Save every N epochs
|
| 87 |
+
|
| 88 |
+
# Early Stopping
|
| 89 |
+
patience: int = 15
|
| 90 |
+
min_delta: float = 0.001
|
| 91 |
+
|
| 92 |
+
# Logging
|
| 93 |
+
log_frequency: int = 100 # Log every N steps
|
| 94 |
+
visualize_frequency: int = 1 # Visualize every N epochs
|
| 95 |
+
|
| 96 |
+
tracking_uri: str = "http://127.0.0.1:5555"
|
| 97 |
+
|
| 98 |
+
def __post_init__(self):
|
| 99 |
+
"""Validate configuration parameters."""
|
| 100 |
+
assert 0.0 <= self.initial_hard_ratio <= 1.0, "Initial hard ratio must be in [0, 1]"
|
| 101 |
+
assert 0.0 <= self.final_hard_ratio <= 1.0, "Final hard ratio must be in [0, 1]"
|
| 102 |
+
assert self.curriculum_strategy in ["progressive", "exponential", "linear"]
|
| 103 |
+
assert self.lr_scheduler in ["cosine", "plateau"]
|
| 104 |
+
assert 0.0 <= self.target_precision <= 1.0, "Target precision must be in [0, 1]"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# Global configuration
|
| 108 |
+
CONFIG = TrainingConfig()
|
| 109 |
+
|
| 110 |
+
# ----------------------------
|
| 111 |
+
# MLFlow Setup
|
| 112 |
+
# ----------------------------
|
| 113 |
+
class MLFlowManager:
|
| 114 |
+
"""Centralized MLflow management for experiment tracking."""
|
| 115 |
+
|
| 116 |
+
def __init__(self, tracking_uri: str = "http://127.0.0.1:5555"):
|
| 117 |
+
mlflow.set_tracking_uri(tracking_uri)
|
| 118 |
+
self.experiment_name = "Signature Verification - Advanced Training"
|
| 119 |
+
self._setup_experiment()
|
| 120 |
+
|
| 121 |
+
def _setup_experiment(self):
|
| 122 |
+
"""Setup MLflow experiment."""
|
| 123 |
+
try:
|
| 124 |
+
self.experiment_id = mlflow.create_experiment(self.experiment_name)
|
| 125 |
+
except:
|
| 126 |
+
self.experiment_id = mlflow.get_experiment_by_name(self.experiment_name).experiment_id
|
| 127 |
+
|
| 128 |
+
def start_run(self, run_id: Optional[str] = None):
|
| 129 |
+
"""Start MLflow run with configuration logging."""
|
| 130 |
+
return mlflow.start_run(run_id=run_id, experiment_id=self.experiment_id)
|
| 131 |
+
|
| 132 |
+
def log_config(self, config: TrainingConfig):
|
| 133 |
+
"""Log training configuration."""
|
| 134 |
+
config_dict = asdict(config)
|
| 135 |
+
mlflow.log_params(config_dict)
|
| 136 |
+
|
| 137 |
+
# ----------------------------
|
| 138 |
+
# Curriculum Learning Manager
|
| 139 |
+
# ----------------------------
|
| 140 |
+
class CurriculumLearningManager:
|
| 141 |
+
"""Advanced curriculum learning for both hard positives and hard negatives."""
|
| 142 |
+
|
| 143 |
+
def __init__(self, config: TrainingConfig):
|
| 144 |
+
self.config = config
|
| 145 |
+
self.current_epoch = 0
|
| 146 |
+
|
| 147 |
+
def get_hard_ratio(self, epoch: int) -> float:
|
| 148 |
+
"""Get hard negative ratio (forgeries) for current epoch."""
|
| 149 |
+
if epoch < self.config.curriculum_warmup_epochs:
|
| 150 |
+
return self.config.initial_hard_ratio
|
| 151 |
+
|
| 152 |
+
# Target: reach final_hard_ratio by max_epochs // 2
|
| 153 |
+
target_epoch = max(self.config.max_epochs // 2, self.config.curriculum_warmup_epochs + 3)
|
| 154 |
+
|
| 155 |
+
if epoch >= target_epoch:
|
| 156 |
+
return self.config.final_hard_ratio
|
| 157 |
+
|
| 158 |
+
# Aggressive progression to reach target by mid-training
|
| 159 |
+
progress = (epoch - self.config.curriculum_warmup_epochs) / (target_epoch - self.config.curriculum_warmup_epochs)
|
| 160 |
+
|
| 161 |
+
initial = self.config.initial_hard_ratio
|
| 162 |
+
final = self.config.final_hard_ratio
|
| 163 |
+
|
| 164 |
+
if self.config.curriculum_strategy == "progressive":
|
| 165 |
+
# Very aggressive: exponential growth early, then plateau
|
| 166 |
+
ratio = initial + (final - initial) * (progress ** 0.5)
|
| 167 |
+
elif self.config.curriculum_strategy == "exponential":
|
| 168 |
+
ratio = initial + (final - initial) * (progress ** 0.3)
|
| 169 |
+
else: # linear
|
| 170 |
+
ratio = initial + (final - initial) * progress
|
| 171 |
+
|
| 172 |
+
return min(max(ratio, 0.0), 1.0)
|
| 173 |
+
|
| 174 |
+
def get_hard_positive_ratio(self, epoch: int) -> float:
|
| 175 |
+
"""Get hard positive ratio for current epoch - increases more gradually."""
|
| 176 |
+
if epoch < self.config.curriculum_warmup_epochs:
|
| 177 |
+
return 0.1 # Start with 10% hard positives
|
| 178 |
+
|
| 179 |
+
# Hard positives should increase more gradually than hard negatives
|
| 180 |
+
max_epochs = self.config.max_epochs
|
| 181 |
+
progress = min(1.0, (epoch - self.config.curriculum_warmup_epochs) / (max_epochs - self.config.curriculum_warmup_epochs))
|
| 182 |
+
|
| 183 |
+
# Target 40% hard positives by end of training
|
| 184 |
+
initial_ratio = 0.1
|
| 185 |
+
final_ratio = 0.4
|
| 186 |
+
|
| 187 |
+
if self.config.curriculum_strategy == "progressive":
|
| 188 |
+
ratio = initial_ratio + (final_ratio - initial_ratio) * (progress ** 0.7)
|
| 189 |
+
else:
|
| 190 |
+
ratio = initial_ratio + (final_ratio - initial_ratio) * progress
|
| 191 |
+
|
| 192 |
+
return min(max(ratio, 0.0), final_ratio)
|
| 193 |
+
|
| 194 |
+
def get_mining_difficulty(self, epoch: int) -> Dict[str, float]:
|
| 195 |
+
"""Adaptive mining parameters for both hard positives and negatives."""
|
| 196 |
+
progress = min(1.0, epoch / self.config.max_epochs)
|
| 197 |
+
|
| 198 |
+
# Separate ratios for hard positives and hard negatives
|
| 199 |
+
hard_negative_ratio = self.get_hard_ratio(epoch)
|
| 200 |
+
hard_positive_ratio = self.get_hard_positive_ratio(epoch)
|
| 201 |
+
|
| 202 |
+
# Dynamic weights for different sample types
|
| 203 |
+
hard_pos_weight = 1.0 + 2.0 * progress # 1.0 → 3.0
|
| 204 |
+
hard_neg_weight = 1.0 + 4.0 * progress # 1.0 → 5.0 (harder negatives more important)
|
| 205 |
+
|
| 206 |
+
return {
|
| 207 |
+
# Margin parameters
|
| 208 |
+
"margin_multiplier": 1.0 + 0.5 * progress,
|
| 209 |
+
|
| 210 |
+
# Hard sample ratios
|
| 211 |
+
"hard_negative_ratio": hard_negative_ratio,
|
| 212 |
+
"hard_positive_ratio": hard_positive_ratio,
|
| 213 |
+
"current_hard_ratio": hard_negative_ratio, # For backward compatibility
|
| 214 |
+
|
| 215 |
+
# Sample weights
|
| 216 |
+
"hard_positive_weight": hard_pos_weight,
|
| 217 |
+
"hard_negative_weight": hard_neg_weight,
|
| 218 |
+
"semi_positive_weight": 1.0 + 1.0 * progress,
|
| 219 |
+
"semi_negative_weight": 1.0 + 2.0 * progress,
|
| 220 |
+
|
| 221 |
+
# Difficulty thresholds
|
| 222 |
+
"difficulty_threshold": 0.05 + 0.15 * progress,
|
| 223 |
+
"selectivity": 0.8 + 0.2 * progress,
|
| 224 |
+
|
| 225 |
+
# Mining aggressiveness
|
| 226 |
+
"mining_temperature": max(0.5, 1.0 - 0.5 * progress), # Decreases over time
|
| 227 |
+
|
| 228 |
+
# Focus balance (0 = equal focus, 1 = focus on negatives)
|
| 229 |
+
"negative_focus": 0.5 + 0.3 * progress
|
| 230 |
+
}
|
| 231 |
+
# ----------------------------
|
| 232 |
+
# Enhanced Dataset with Advanced Curriculum Learning
|
| 233 |
+
# ----------------------------
|
| 234 |
+
class SignatureDataset(Dataset):
|
| 235 |
+
"""
|
| 236 |
+
Advanced signature dataset with curriculum learning and mining statistics.
|
| 237 |
+
"""
|
| 238 |
+
|
| 239 |
+
def __init__(
|
| 240 |
+
self,
|
| 241 |
+
folder_img: str,
|
| 242 |
+
excel_data: pd.DataFrame,
|
| 243 |
+
curriculum_manager: CurriculumLearningManager,
|
| 244 |
+
transform: Optional[transforms.Compose] = None,
|
| 245 |
+
is_train: bool = True,
|
| 246 |
+
config: TrainingConfig = CONFIG
|
| 247 |
+
):
|
| 248 |
+
self.folder_img = folder_img
|
| 249 |
+
self.is_train = is_train
|
| 250 |
+
self.config = config
|
| 251 |
+
self.curriculum_manager = curriculum_manager
|
| 252 |
+
self.transform = transform or self._default_transforms()
|
| 253 |
+
self.excel_data = excel_data.reset_index(drop=True)
|
| 254 |
+
self.current_epoch = 0
|
| 255 |
+
|
| 256 |
+
# Data preparation
|
| 257 |
+
self._handle_excel_person_ids()
|
| 258 |
+
self._categorize_difficulty()
|
| 259 |
+
|
| 260 |
+
# Curriculum learning data
|
| 261 |
+
self.epoch_data = []
|
| 262 |
+
self._prepare_epoch_data()
|
| 263 |
+
def _handle_excel_person_ids(self):
|
| 264 |
+
"""Properly separate genuine vs forged signature IDs with compact offset."""
|
| 265 |
+
# Map genuine person IDs to 0, 1, 2, ...
|
| 266 |
+
genuine_ids = pd.concat([
|
| 267 |
+
self.excel_data["anchor_id"],
|
| 268 |
+
self.excel_data[self.excel_data["easy_or_hard"] == "easy"]["negative_id"]
|
| 269 |
+
]).unique()
|
| 270 |
+
|
| 271 |
+
self.genuine_id_mapping = {val: idx for idx, val in enumerate(genuine_ids)}
|
| 272 |
+
max_genuine_id = len(genuine_ids)
|
| 273 |
+
|
| 274 |
+
# Create forgery ID space with SMALLER offset (just enough to avoid collisions)
|
| 275 |
+
forged_data = self.excel_data[self.excel_data["easy_or_hard"] == "hard"]
|
| 276 |
+
if len(forged_data) > 0:
|
| 277 |
+
unique_forged_persons = forged_data["negative_id"].unique()
|
| 278 |
+
self.forgery_id_mapping = {
|
| 279 |
+
val: idx + max_genuine_id + 100 # Smaller offset: 100 instead of 1000
|
| 280 |
+
for idx, val in enumerate(unique_forged_persons)
|
| 281 |
+
}
|
| 282 |
+
else:
|
| 283 |
+
self.forgery_id_mapping = {}
|
| 284 |
+
|
| 285 |
+
# Apply mappings
|
| 286 |
+
self.excel_data["anchor_id"] = self.excel_data["anchor_id"].map(self.genuine_id_mapping)
|
| 287 |
+
|
| 288 |
+
# Handle negatives based on type
|
| 289 |
+
new_negative_ids = []
|
| 290 |
+
for idx, row in self.excel_data.iterrows():
|
| 291 |
+
if row["easy_or_hard"] == "easy":
|
| 292 |
+
# Genuine different person: use regular ID
|
| 293 |
+
new_negative_ids.append(self.genuine_id_mapping[row["negative_id"]])
|
| 294 |
+
else:
|
| 295 |
+
# Forged signature: use offset ID to prevent clustering with genuine
|
| 296 |
+
new_negative_ids.append(self.forgery_id_mapping[row["negative_id"]])
|
| 297 |
+
|
| 298 |
+
self.excel_data["negative_id"] = new_negative_ids
|
| 299 |
+
|
| 300 |
+
print(f"ID mapping: Genuine IDs 0-{max_genuine_id-1}, Forgery IDs {max_genuine_id+100}-{max_genuine_id+100+len(self.forgery_id_mapping)-1}")
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _categorize_difficulty(self):
|
| 304 |
+
"""Categorize samples by difficulty if not already done."""
|
| 305 |
+
if self.is_train and "easy_or_hard" in self.excel_data.columns:
|
| 306 |
+
self.easy_df = self.excel_data[self.excel_data["easy_or_hard"] == "easy"]
|
| 307 |
+
self.hard_df = self.excel_data[self.excel_data["easy_or_hard"] == "hard"]
|
| 308 |
+
else:
|
| 309 |
+
# All samples treated as medium difficulty
|
| 310 |
+
self.easy_df = self.excel_data
|
| 311 |
+
self.hard_df = pd.DataFrame() # Empty hard samples
|
| 312 |
+
|
| 313 |
+
def _prepare_epoch_data(self):
|
| 314 |
+
"""Prepare data for current epoch based on curriculum."""
|
| 315 |
+
if not self.is_train:
|
| 316 |
+
# Validation data preparation with better error handling
|
| 317 |
+
if "image_1_path" in self.excel_data.columns and "image_2_path" in self.excel_data.columns:
|
| 318 |
+
# Standard pair format
|
| 319 |
+
required_cols = ["image_1_path", "image_2_path", "label"]
|
| 320 |
+
|
| 321 |
+
# Find ID columns
|
| 322 |
+
id_cols = [col for col in self.excel_data.columns if "id" in col.lower()]
|
| 323 |
+
if len(id_cols) >= 2:
|
| 324 |
+
required_cols.extend(id_cols[-2:]) # Take last 2 ID columns
|
| 325 |
+
else:
|
| 326 |
+
# Create dummy IDs if none exist
|
| 327 |
+
self.excel_data["dummy_id1"] = 0
|
| 328 |
+
self.excel_data["dummy_id2"] = 1
|
| 329 |
+
required_cols.extend(["dummy_id1", "dummy_id2"])
|
| 330 |
+
|
| 331 |
+
self.epoch_data = self.excel_data[required_cols].values.tolist()
|
| 332 |
+
|
| 333 |
+
else:
|
| 334 |
+
# Fallback: try to use all available columns
|
| 335 |
+
print(f"Warning: Expected validation columns not found. Available: {list(self.excel_data.columns)}")
|
| 336 |
+
self.epoch_data = self.excel_data.values.tolist()
|
| 337 |
+
|
| 338 |
+
print(f"Validation data prepared: {len(self.epoch_data)} samples")
|
| 339 |
+
return
|
| 340 |
+
|
| 341 |
+
# Training data preparation (unchanged)
|
| 342 |
+
hard_ratio = self.curriculum_manager.get_hard_ratio(self.current_epoch)
|
| 343 |
+
|
| 344 |
+
if len(self.hard_df) > 0:
|
| 345 |
+
n_total = len(self.excel_data)
|
| 346 |
+
n_hard = int(n_total * hard_ratio)
|
| 347 |
+
n_easy = n_total - n_hard
|
| 348 |
+
|
| 349 |
+
hard_sample = self.hard_df.sample(
|
| 350 |
+
n=min(n_hard, len(self.hard_df)),
|
| 351 |
+
random_state=self.current_epoch,
|
| 352 |
+
replace=(n_hard > len(self.hard_df))
|
| 353 |
+
)
|
| 354 |
+
easy_sample = self.easy_df.sample(
|
| 355 |
+
n=min(n_easy, len(self.easy_df)),
|
| 356 |
+
random_state=self.current_epoch,
|
| 357 |
+
replace=(n_easy > len(self.easy_df))
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
epoch_df = pd.concat([hard_sample, easy_sample]).sample(
|
| 361 |
+
frac=1, random_state=self.current_epoch
|
| 362 |
+
).reset_index(drop=True)
|
| 363 |
+
|
| 364 |
+
print(f"Epoch {self.current_epoch}: {len(hard_sample)} hard + {len(easy_sample)} easy = {len(epoch_df)} total (target ratio: {hard_ratio:.2f})")
|
| 365 |
+
else:
|
| 366 |
+
epoch_df = self.excel_data.sample(
|
| 367 |
+
frac=1, random_state=self.current_epoch
|
| 368 |
+
).reset_index(drop=True)
|
| 369 |
+
|
| 370 |
+
required_cols = ["anchor_path", "positive_path", "negative_path", "anchor_id", "negative_id"]
|
| 371 |
+
missing_cols = [col for col in required_cols if col not in epoch_df.columns]
|
| 372 |
+
if missing_cols:
|
| 373 |
+
raise ValueError(f"Missing required training columns: {missing_cols}")
|
| 374 |
+
|
| 375 |
+
self.epoch_data = epoch_df[required_cols].values.tolist()
|
| 376 |
+
|
| 377 |
+
def set_epoch(self, epoch: int):
|
| 378 |
+
"""Update epoch and regenerate data."""
|
| 379 |
+
self.current_epoch = epoch
|
| 380 |
+
self._prepare_epoch_data()
|
| 381 |
+
|
| 382 |
+
def get_curriculum_stats(self) -> Dict[str, Any]:
|
| 383 |
+
"""Get current curriculum learning statistics."""
|
| 384 |
+
hard_ratio = self.curriculum_manager.get_hard_ratio(self.current_epoch)
|
| 385 |
+
mining_params = self.curriculum_manager.get_mining_difficulty(self.current_epoch)
|
| 386 |
+
|
| 387 |
+
return {
|
| 388 |
+
"epoch": self.current_epoch,
|
| 389 |
+
"hard_ratio": hard_ratio,
|
| 390 |
+
"easy_ratio": 1.0 - hard_ratio,
|
| 391 |
+
"total_samples": len(self.epoch_data),
|
| 392 |
+
**mining_params
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
def __len__(self) -> int:
|
| 396 |
+
return len(self.epoch_data)
|
| 397 |
+
|
| 398 |
+
def __getitem__(self, index: int) -> Tuple[torch.Tensor, ...]:
|
| 399 |
+
if self.is_train:
|
| 400 |
+
return self._get_train_item(index)
|
| 401 |
+
else:
|
| 402 |
+
return self._get_val_item(index)
|
| 403 |
+
|
| 404 |
+
def _get_train_item(self, index: int) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, int, int]:
|
| 405 |
+
"""Return triplet: anchor, positive, negative with their IDs."""
|
| 406 |
+
anchor_path, positive_path, negative_path, pid, nid = self.epoch_data[index]
|
| 407 |
+
|
| 408 |
+
anchor = self._load_image(anchor_path)
|
| 409 |
+
positive = self._load_image(positive_path)
|
| 410 |
+
negative = self._load_image(negative_path)
|
| 411 |
+
|
| 412 |
+
return anchor, positive, negative, int(pid), int(nid)
|
| 413 |
+
|
| 414 |
+
def _get_val_item(self, index: int) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, int, int]:
|
| 415 |
+
"""Return: img1, img2, label, id1, id2."""
|
| 416 |
+
data_row = self.epoch_data[index]
|
| 417 |
+
|
| 418 |
+
# Handle different data formats robustly
|
| 419 |
+
if len(data_row) >= 5:
|
| 420 |
+
img1_path, img2_path, label, id1, id2 = data_row[:5]
|
| 421 |
+
elif len(data_row) >= 3:
|
| 422 |
+
img1_path, img2_path, label = data_row[:3]
|
| 423 |
+
# Fallback IDs
|
| 424 |
+
id1, id2 = 0, 1
|
| 425 |
+
else:
|
| 426 |
+
raise ValueError(f"Invalid validation data format: expected at least 3 columns, got {len(data_row)}")
|
| 427 |
+
|
| 428 |
+
try:
|
| 429 |
+
img1 = self._load_image(img1_path)
|
| 430 |
+
img2 = self._load_image(img2_path)
|
| 431 |
+
|
| 432 |
+
return img1, img2, torch.tensor(float(label), dtype=torch.float32), int(id1), int(id2)
|
| 433 |
+
except Exception as e:
|
| 434 |
+
print(f"Error loading validation item {index}: {e}")
|
| 435 |
+
print(f"Data row: {data_row}")
|
| 436 |
+
raise
|
| 437 |
+
|
| 438 |
+
def _load_image(self, path: str) -> torch.Tensor:
|
| 439 |
+
"""Load and transform image."""
|
| 440 |
+
image = replace_background_with_white(
|
| 441 |
+
path, self.folder_img, remove_bg=self.config.remove_bg
|
| 442 |
+
)
|
| 443 |
+
return self.transform(image) if self.transform else image
|
| 444 |
+
|
| 445 |
+
def _default_transforms(self) -> transforms.Compose:
|
| 446 |
+
"""Get default transforms with configurable augmentation strength."""
|
| 447 |
+
normalize = transforms.Normalize(
|
| 448 |
+
mean=[0.485, 0.456, 0.406],
|
| 449 |
+
std=[0.229, 0.224, 0.225]
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
if self.is_train:
|
| 453 |
+
aug_strength = self.config.augmentation_strength
|
| 454 |
+
return transforms.Compose([
|
| 455 |
+
transforms.Resize((224, 224)),
|
| 456 |
+
transforms.RandomHorizontalFlip(p=0.5 * aug_strength),
|
| 457 |
+
transforms.RandomRotation(degrees=int(10 * aug_strength)),
|
| 458 |
+
transforms.ColorJitter(
|
| 459 |
+
brightness=0.2 * aug_strength,
|
| 460 |
+
contrast=0.2 * aug_strength
|
| 461 |
+
),
|
| 462 |
+
transforms.GaussianBlur(kernel_size=5, sigma=(0.1, 2.0 * aug_strength)),
|
| 463 |
+
transforms.ToTensor(),
|
| 464 |
+
normalize
|
| 465 |
+
])
|
| 466 |
+
|
| 467 |
+
return transforms.Compose([
|
| 468 |
+
transforms.Resize((224, 224)),
|
| 469 |
+
transforms.ToTensor(),
|
| 470 |
+
normalize
|
| 471 |
+
])
|
| 472 |
+
|
| 473 |
+
# ----------------------------
|
| 474 |
+
# Enhanced Model Architecture
|
| 475 |
+
# ----------------------------
|
| 476 |
+
class ResNetBackbone(nn.Module):
|
| 477 |
+
"""Enhanced ResNet backbone with better weight loading."""
|
| 478 |
+
|
| 479 |
+
def __init__(self, model_name: str = "resnet34", pretrained_path: Optional[str] = None):
|
| 480 |
+
super().__init__()
|
| 481 |
+
|
| 482 |
+
# Initialize the ResNet model
|
| 483 |
+
if model_name == "resnet18":
|
| 484 |
+
self.resnet = models.resnet18(weights=None)
|
| 485 |
+
elif model_name == "resnet34":
|
| 486 |
+
self.resnet = models.resnet34(weights=None)
|
| 487 |
+
elif model_name == "resnet50":
|
| 488 |
+
self.resnet = models.resnet50(weights=None)
|
| 489 |
+
else:
|
| 490 |
+
raise ValueError(f"Unsupported model_name: {model_name}")
|
| 491 |
+
|
| 492 |
+
# Load pretrained weights
|
| 493 |
+
if pretrained_path and os.path.exists(pretrained_path):
|
| 494 |
+
self._load_pretrained_weights(pretrained_path)
|
| 495 |
+
elif pretrained_path:
|
| 496 |
+
print(f"Warning: Pretrained path {pretrained_path} not found, using random initialization")
|
| 497 |
+
|
| 498 |
+
# Remove the fully connected layer
|
| 499 |
+
self.resnet.fc = nn.Identity()
|
| 500 |
+
|
| 501 |
+
# Get output dimension
|
| 502 |
+
with torch.no_grad():
|
| 503 |
+
dummy = torch.randn(1, 3, 224, 224)
|
| 504 |
+
self.output_dim = self.resnet(dummy).shape[1]
|
| 505 |
+
|
| 506 |
+
def _load_pretrained_weights(self, pretrained_path: str):
|
| 507 |
+
"""Load pretrained weights with comprehensive error handling."""
|
| 508 |
+
try:
|
| 509 |
+
checkpoint = torch.load(pretrained_path, map_location="cpu", weights_only=False)
|
| 510 |
+
state_dict = checkpoint.get("state_dict", checkpoint)
|
| 511 |
+
|
| 512 |
+
# Handle prefix issues
|
| 513 |
+
if not any(key.startswith("resnet.") for key in state_dict.keys()):
|
| 514 |
+
state_dict = {f"resnet.{k}": v for k, v in state_dict.items()}
|
| 515 |
+
|
| 516 |
+
# Filter matching keys and sizes
|
| 517 |
+
model_dict = self.state_dict()
|
| 518 |
+
filtered_state_dict = {
|
| 519 |
+
k: v for k, v in state_dict.items()
|
| 520 |
+
if k in model_dict and v.size() == model_dict[k].size()
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
# Load filtered weights
|
| 524 |
+
missing_keys = self.load_state_dict(filtered_state_dict, strict=False)
|
| 525 |
+
|
| 526 |
+
print(f"[INFO] Loaded pretrained weights: {len(filtered_state_dict)}/{len(model_dict)} parameters")
|
| 527 |
+
if missing_keys.missing_keys:
|
| 528 |
+
print(f"[INFO] Missing keys: {len(missing_keys.missing_keys)}")
|
| 529 |
+
|
| 530 |
+
except Exception as e:
|
| 531 |
+
print(f"[ERROR] Failed to load pretrained weights: {e}")
|
| 532 |
+
raise
|
| 533 |
+
|
| 534 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 535 |
+
return self.resnet(x)
|
| 536 |
+
|
| 537 |
+
class AdvancedEmbeddingHead(nn.Module):
|
| 538 |
+
"""Advanced embedding head with residual connections and normalization."""
|
| 539 |
+
|
| 540 |
+
def __init__(self, input_dim: int, embedding_dim: int, dropout: float = 0.5):
|
| 541 |
+
super().__init__()
|
| 542 |
+
|
| 543 |
+
self.input_dim = input_dim
|
| 544 |
+
self.embedding_dim = embedding_dim
|
| 545 |
+
|
| 546 |
+
# Multi-layer embedding head with residual connections
|
| 547 |
+
if input_dim > embedding_dim * 4:
|
| 548 |
+
hidden_dim = max(embedding_dim * 2, input_dim // 4)
|
| 549 |
+
self.layers = nn.Sequential(
|
| 550 |
+
nn.Linear(input_dim, hidden_dim),
|
| 551 |
+
nn.LayerNorm(hidden_dim),
|
| 552 |
+
nn.GELU(),
|
| 553 |
+
nn.Dropout(dropout),
|
| 554 |
+
|
| 555 |
+
nn.Linear(hidden_dim, embedding_dim * 2),
|
| 556 |
+
nn.LayerNorm(embedding_dim * 2),
|
| 557 |
+
nn.GELU(),
|
| 558 |
+
nn.Dropout(dropout / 2),
|
| 559 |
+
|
| 560 |
+
nn.Linear(embedding_dim * 2, embedding_dim),
|
| 561 |
+
nn.LayerNorm(embedding_dim)
|
| 562 |
+
)
|
| 563 |
+
else:
|
| 564 |
+
# Simple head for smaller dimensions
|
| 565 |
+
self.layers = nn.Sequential(
|
| 566 |
+
nn.Linear(input_dim, embedding_dim),
|
| 567 |
+
nn.LayerNorm(embedding_dim)
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 571 |
+
x = x.flatten(1) # Flatten spatial dimensions
|
| 572 |
+
return self.layers(x)
|
| 573 |
+
|
| 574 |
+
class SiameseSignatureNetwork(nn.Module):
|
| 575 |
+
"""Advanced Siamese network with precision-focused loss."""
|
| 576 |
+
|
| 577 |
+
def __init__(self, config: TrainingConfig = CONFIG):
|
| 578 |
+
super().__init__()
|
| 579 |
+
self.config = config
|
| 580 |
+
|
| 581 |
+
# Initialize backbone
|
| 582 |
+
if config.model_name.startswith("resnet"):
|
| 583 |
+
self.backbone = ResNetBackbone(
|
| 584 |
+
model_name=config.model_name,
|
| 585 |
+
pretrained_path=config.pretrained_path if config.last_epoch_weights is None else None
|
| 586 |
+
)
|
| 587 |
+
backbone_dim = self.backbone.output_dim
|
| 588 |
+
else:
|
| 589 |
+
raise ValueError(f"Unsupported model: {config.model_name}")
|
| 590 |
+
|
| 591 |
+
# Initialize embedding head
|
| 592 |
+
self.embedding_head = AdvancedEmbeddingHead(
|
| 593 |
+
input_dim=backbone_dim,
|
| 594 |
+
embedding_dim=config.embedding_dim,
|
| 595 |
+
dropout=0.5
|
| 596 |
+
)
|
| 597 |
+
|
| 598 |
+
self.normalize_embeddings = config.normalize_embeddings
|
| 599 |
+
self.distance_threshold = 0.5 # Will be updated during validation
|
| 600 |
+
|
| 601 |
+
# Loss components
|
| 602 |
+
self.criterion = MultiSimilarityLoss(
|
| 603 |
+
alpha=config.multisim_alpha,
|
| 604 |
+
beta=config.multisim_beta,
|
| 605 |
+
base=config.multisim_base
|
| 606 |
+
)
|
| 607 |
+
|
| 608 |
+
# Add triplet margin loss for better separation
|
| 609 |
+
self.triplet_loss = nn.TripletMarginLoss(
|
| 610 |
+
margin=config.triplet_margin,
|
| 611 |
+
p=2,
|
| 612 |
+
reduction='none' # We'll apply weights manually
|
| 613 |
+
)
|
| 614 |
+
|
| 615 |
+
# Loss weights
|
| 616 |
+
self.triplet_weight = config.triplet_weight
|
| 617 |
+
self.fp_penalty_weight = config.false_positive_penalty_weight
|
| 618 |
+
|
| 619 |
+
# Mining
|
| 620 |
+
if config.use_hard_mining:
|
| 621 |
+
self.miner = BatchHardMiner()
|
| 622 |
+
else:
|
| 623 |
+
self.miner = None
|
| 624 |
+
|
| 625 |
+
def get_parameter_groups(self) -> List[Dict[str, Any]]:
|
| 626 |
+
"""Get parameter groups for differential learning rates."""
|
| 627 |
+
backbone_params = list(self.backbone.parameters())
|
| 628 |
+
head_params = list(self.embedding_head.parameters())
|
| 629 |
+
|
| 630 |
+
return [
|
| 631 |
+
{
|
| 632 |
+
'params': backbone_params,
|
| 633 |
+
'lr': self.config.backbone_lr,
|
| 634 |
+
'name': 'backbone',
|
| 635 |
+
'weight_decay': self.config.weight_decay
|
| 636 |
+
},
|
| 637 |
+
{
|
| 638 |
+
'params': head_params,
|
| 639 |
+
'lr': self.config.head_lr,
|
| 640 |
+
'name': 'embedding_head',
|
| 641 |
+
'weight_decay': self.config.weight_decay
|
| 642 |
+
}
|
| 643 |
+
]
|
| 644 |
+
|
| 645 |
+
def forward(self, anchor: torch.Tensor, positive: torch.Tensor,
|
| 646 |
+
negative: Optional[torch.Tensor] = None) -> Union[Tuple[torch.Tensor, torch.Tensor],
|
| 647 |
+
Tuple[torch.Tensor, torch.Tensor, torch.Tensor]]:
|
| 648 |
+
"""Forward pass for training or inference."""
|
| 649 |
+
a_features = self.backbone(anchor)
|
| 650 |
+
a_emb = self.embedding_head(a_features)
|
| 651 |
+
|
| 652 |
+
p_features = self.backbone(positive)
|
| 653 |
+
p_emb = self.embedding_head(p_features)
|
| 654 |
+
|
| 655 |
+
if self.normalize_embeddings:
|
| 656 |
+
a_emb = F.normalize(a_emb, p=2, dim=1)
|
| 657 |
+
p_emb = F.normalize(p_emb, p=2, dim=1)
|
| 658 |
+
|
| 659 |
+
if negative is not None:
|
| 660 |
+
n_features = self.backbone(negative)
|
| 661 |
+
n_emb = self.embedding_head(n_features)
|
| 662 |
+
|
| 663 |
+
if self.normalize_embeddings:
|
| 664 |
+
n_emb = F.normalize(n_emb, p=2, dim=1)
|
| 665 |
+
|
| 666 |
+
return a_emb, p_emb, n_emb
|
| 667 |
+
|
| 668 |
+
return a_emb, p_emb
|
| 669 |
+
|
| 670 |
+
def compute_loss(self, embeddings: torch.Tensor, labels: torch.Tensor,
|
| 671 |
+
anchors: Optional[torch.Tensor] = None,
|
| 672 |
+
positives: Optional[torch.Tensor] = None,
|
| 673 |
+
negatives: Optional[torch.Tensor] = None,
|
| 674 |
+
distance_weights: Optional[Dict[str, torch.Tensor]] = None) -> torch.Tensor:
|
| 675 |
+
"""Enhanced loss computation with precision focus and distance weighting."""
|
| 676 |
+
|
| 677 |
+
# MultiSimilarity loss
|
| 678 |
+
if self.miner is not None:
|
| 679 |
+
hard_pairs = self.miner(embeddings, labels)
|
| 680 |
+
ms_loss = self.criterion(embeddings, labels, hard_pairs)
|
| 681 |
+
else:
|
| 682 |
+
ms_loss = self.criterion(embeddings, labels)
|
| 683 |
+
|
| 684 |
+
total_loss = ms_loss
|
| 685 |
+
|
| 686 |
+
# Add triplet loss if embeddings provided
|
| 687 |
+
if anchors is not None and positives is not None and negatives is not None:
|
| 688 |
+
# Compute triplet losses for each sample
|
| 689 |
+
triplet_losses = self.triplet_loss(anchors, positives, negatives)
|
| 690 |
+
|
| 691 |
+
# Apply distance-based weights if provided
|
| 692 |
+
if distance_weights is not None:
|
| 693 |
+
neg_weights = distance_weights.get('negative_weights', torch.ones_like(triplet_losses))
|
| 694 |
+
weighted_triplet_loss = (triplet_losses * neg_weights).mean()
|
| 695 |
+
else:
|
| 696 |
+
weighted_triplet_loss = triplet_losses.mean()
|
| 697 |
+
|
| 698 |
+
total_loss += self.triplet_weight * weighted_triplet_loss
|
| 699 |
+
|
| 700 |
+
# Additional penalty for hard negatives (false positives)
|
| 701 |
+
with torch.no_grad():
|
| 702 |
+
d_an = F.pairwise_distance(anchors, negatives)
|
| 703 |
+
# Find negatives that are too close (potential false positives)
|
| 704 |
+
hard_negative_mask = d_an < self.distance_threshold
|
| 705 |
+
|
| 706 |
+
if hard_negative_mask.any():
|
| 707 |
+
# Apply distance-based weights for false positive penalty
|
| 708 |
+
if distance_weights is not None:
|
| 709 |
+
neg_weights = distance_weights.get('negative_weights', torch.ones_like(d_an))
|
| 710 |
+
# Extra penalty weighted by how bad the false positive is
|
| 711 |
+
false_positive_distances = self.distance_threshold - d_an[hard_negative_mask]
|
| 712 |
+
false_positive_weights = neg_weights[hard_negative_mask]
|
| 713 |
+
fp_loss = (false_positive_distances * false_positive_weights).mean()
|
| 714 |
+
else:
|
| 715 |
+
fp_loss = (self.distance_threshold - d_an[hard_negative_mask]).mean()
|
| 716 |
+
|
| 717 |
+
total_loss += self.fp_penalty_weight * fp_loss
|
| 718 |
+
|
| 719 |
+
return total_loss
|
| 720 |
+
|
| 721 |
+
def predict_pair(self, img1: torch.Tensor, img2: torch.Tensor,
|
| 722 |
+
threshold: Optional[float] = None, return_dist: bool = False) -> torch.Tensor:
|
| 723 |
+
"""Predict similarity between image pairs."""
|
| 724 |
+
self.eval()
|
| 725 |
+
with torch.no_grad():
|
| 726 |
+
emb1, emb2 = self(img1, img2)
|
| 727 |
+
distances = F.pairwise_distance(emb1, emb2)
|
| 728 |
+
|
| 729 |
+
if return_dist:
|
| 730 |
+
return distances
|
| 731 |
+
|
| 732 |
+
thresh = threshold if threshold is not None else self.distance_threshold
|
| 733 |
+
return (distances < thresh).long()
|
| 734 |
+
|
| 735 |
+
# ----------------------------
|
| 736 |
+
# Advanced Training Metrics and Statistics
|
| 737 |
+
# ----------------------------
|
| 738 |
+
class TrainingMetrics:
|
| 739 |
+
"""Enhanced training metrics with adaptive mining for both hard positives and negatives."""
|
| 740 |
+
|
| 741 |
+
def __init__(self):
|
| 742 |
+
self.reset()
|
| 743 |
+
# Track distance statistics for adaptive thresholds
|
| 744 |
+
self.distance_history = {"positive": [], "negative": []}
|
| 745 |
+
self.adaptive_stats = {}
|
| 746 |
+
|
| 747 |
+
def reset(self):
|
| 748 |
+
"""Reset all metrics."""
|
| 749 |
+
self.losses = []
|
| 750 |
+
self.genuine_distances = []
|
| 751 |
+
self.forged_distances = []
|
| 752 |
+
|
| 753 |
+
# Separate mining stats for positives and negatives
|
| 754 |
+
self.positive_mining_stats = {"easy": 0, "semi": 0, "hard": 0}
|
| 755 |
+
self.negative_mining_stats = {"easy": 0, "semi": 0, "hard": 0}
|
| 756 |
+
|
| 757 |
+
# Hard sample counts
|
| 758 |
+
self.hard_positive_count = 0
|
| 759 |
+
self.hard_negative_count = 0
|
| 760 |
+
self.total_positive_pairs = 0
|
| 761 |
+
self.total_negative_pairs = 0
|
| 762 |
+
|
| 763 |
+
# False positive/negative tracking
|
| 764 |
+
self.false_positive_count = 0
|
| 765 |
+
self.false_negative_count = 0
|
| 766 |
+
|
| 767 |
+
self.learning_rates = {}
|
| 768 |
+
|
| 769 |
+
def update_distance_statistics(self, d_positive: np.ndarray, d_negative: np.ndarray):
|
| 770 |
+
"""Update running statistics for adaptive thresholds."""
|
| 771 |
+
# Keep rolling window of recent distances
|
| 772 |
+
self.distance_history["positive"].extend(d_positive.tolist())
|
| 773 |
+
self.distance_history["negative"].extend(d_negative.tolist())
|
| 774 |
+
|
| 775 |
+
# Keep only recent history (last 5000 samples)
|
| 776 |
+
for key in self.distance_history:
|
| 777 |
+
if len(self.distance_history[key]) > 5000:
|
| 778 |
+
self.distance_history[key] = self.distance_history[key][-5000:]
|
| 779 |
+
|
| 780 |
+
# Compute adaptive statistics
|
| 781 |
+
if len(self.distance_history["positive"]) > 100 and len(self.distance_history["negative"]) > 100:
|
| 782 |
+
pos_distances = np.array(self.distance_history["positive"])
|
| 783 |
+
neg_distances = np.array(self.distance_history["negative"])
|
| 784 |
+
|
| 785 |
+
self.adaptive_stats = {
|
| 786 |
+
"pos_mean": np.mean(pos_distances),
|
| 787 |
+
"pos_std": np.std(pos_distances),
|
| 788 |
+
"pos_q25": np.percentile(pos_distances, 25),
|
| 789 |
+
"pos_q50": np.percentile(pos_distances, 50),
|
| 790 |
+
"pos_q75": np.percentile(pos_distances, 75),
|
| 791 |
+
"pos_q90": np.percentile(pos_distances, 90),
|
| 792 |
+
|
| 793 |
+
"neg_mean": np.mean(neg_distances),
|
| 794 |
+
"neg_std": np.std(neg_distances),
|
| 795 |
+
"neg_q10": np.percentile(neg_distances, 10),
|
| 796 |
+
"neg_q25": np.percentile(neg_distances, 25),
|
| 797 |
+
"neg_q50": np.percentile(neg_distances, 50),
|
| 798 |
+
"neg_q75": np.percentile(neg_distances, 75),
|
| 799 |
+
|
| 800 |
+
"separation": np.mean(neg_distances) - np.mean(pos_distances),
|
| 801 |
+
"overlap_region": max(0, np.percentile(pos_distances, 95) - np.percentile(neg_distances, 5))
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
def compute_precision_focused_weights(self, d_positive: np.ndarray,
|
| 805 |
+
d_negative: np.ndarray,
|
| 806 |
+
negative_weight_multiplier: float = 2.5) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 807 |
+
"""Compute sample weights with focus on improving precision."""
|
| 808 |
+
pos_weights = np.ones_like(d_positive)
|
| 809 |
+
neg_weights = np.ones_like(d_negative)
|
| 810 |
+
|
| 811 |
+
if self.adaptive_stats:
|
| 812 |
+
# Hard negatives (forged that look genuine) get MUCH higher weight
|
| 813 |
+
neg_q10 = self.adaptive_stats["neg_q10"]
|
| 814 |
+
neg_q25 = self.adaptive_stats["neg_q25"]
|
| 815 |
+
|
| 816 |
+
# Very hard negatives (bottom 10%) - highest weight
|
| 817 |
+
very_hard_neg_mask = d_negative < neg_q10
|
| 818 |
+
neg_weights[very_hard_neg_mask] = negative_weight_multiplier * 1.5
|
| 819 |
+
|
| 820 |
+
# Hard negatives (10-25%) - high weight
|
| 821 |
+
hard_neg_mask = (d_negative >= neg_q10) & (d_negative < neg_q25)
|
| 822 |
+
neg_weights[hard_neg_mask] = negative_weight_multiplier
|
| 823 |
+
|
| 824 |
+
# Semi-hard negatives (25-50%) - moderate weight
|
| 825 |
+
semi_neg_mask = (d_negative >= neg_q25) & (d_negative < self.adaptive_stats["neg_q50"])
|
| 826 |
+
neg_weights[semi_neg_mask] = negative_weight_multiplier * 0.6
|
| 827 |
+
|
| 828 |
+
# Hard positives get moderate weight (but less than hard negatives)
|
| 829 |
+
pos_q75 = self.adaptive_stats["pos_q75"]
|
| 830 |
+
pos_q90 = self.adaptive_stats["pos_q90"]
|
| 831 |
+
|
| 832 |
+
# Very hard positives (top 10%)
|
| 833 |
+
very_hard_pos_mask = d_positive > pos_q90
|
| 834 |
+
pos_weights[very_hard_pos_mask] = 1.8
|
| 835 |
+
|
| 836 |
+
# Hard positives (75-90%)
|
| 837 |
+
hard_pos_mask = (d_positive > pos_q75) & (d_positive <= pos_q90)
|
| 838 |
+
pos_weights[hard_pos_mask] = 1.5
|
| 839 |
+
|
| 840 |
+
return torch.tensor(pos_weights, dtype=torch.float32), torch.tensor(neg_weights, dtype=torch.float32)
|
| 841 |
+
|
| 842 |
+
def update_mining_stats(self, d_positive: np.ndarray, d_negative: np.ndarray,
|
| 843 |
+
margin: float, difficulty_params: Dict[str, float]):
|
| 844 |
+
"""Intelligent adaptive mining for both hard positives and hard negatives."""
|
| 845 |
+
|
| 846 |
+
# Update distance statistics first
|
| 847 |
+
self.update_distance_statistics(d_positive, d_negative)
|
| 848 |
+
|
| 849 |
+
# Update totals
|
| 850 |
+
self.total_positive_pairs += len(d_positive)
|
| 851 |
+
self.total_negative_pairs += len(d_negative)
|
| 852 |
+
|
| 853 |
+
# Use adaptive thresholds if available, otherwise fallback to fixed
|
| 854 |
+
if self.adaptive_stats:
|
| 855 |
+
self._adaptive_mining(d_positive, d_negative, difficulty_params)
|
| 856 |
+
else:
|
| 857 |
+
self._fixed_mining(d_positive, d_negative, margin)
|
| 858 |
+
|
| 859 |
+
def _adaptive_mining(self, d_positive: np.ndarray, d_negative: np.ndarray,
|
| 860 |
+
difficulty_params: Dict[str, float]):
|
| 861 |
+
"""Adaptive mining based on current distance distributions."""
|
| 862 |
+
stats = self.adaptive_stats
|
| 863 |
+
|
| 864 |
+
# Get difficulty parameters
|
| 865 |
+
hard_positive_ratio = difficulty_params.get("hard_positive_ratio", 0.3)
|
| 866 |
+
hard_negative_ratio = difficulty_params.get("hard_negative_ratio", 0.3)
|
| 867 |
+
|
| 868 |
+
# Dynamic thresholds for hard positives (far apart genuine pairs)
|
| 869 |
+
# Use percentile based on desired hard positive ratio
|
| 870 |
+
hard_pos_percentile = 100 - (hard_positive_ratio * 100)
|
| 871 |
+
hard_pos_threshold = np.percentile(self.distance_history["positive"][-1000:], hard_pos_percentile)
|
| 872 |
+
semi_pos_threshold = stats["pos_q50"]
|
| 873 |
+
|
| 874 |
+
# Dynamic thresholds for hard negatives (close together impostor pairs)
|
| 875 |
+
# Use percentile based on desired hard negative ratio
|
| 876 |
+
hard_neg_percentile = hard_negative_ratio * 100
|
| 877 |
+
hard_neg_threshold = np.percentile(self.distance_history["negative"][-1000:], hard_neg_percentile)
|
| 878 |
+
semi_neg_threshold = stats["neg_q50"]
|
| 879 |
+
|
| 880 |
+
# Mine hard positives
|
| 881 |
+
for dp in d_positive:
|
| 882 |
+
if dp >= hard_pos_threshold:
|
| 883 |
+
self.positive_mining_stats["hard"] += 1
|
| 884 |
+
self.hard_positive_count += 1
|
| 885 |
+
elif dp >= semi_pos_threshold:
|
| 886 |
+
self.positive_mining_stats["semi"] += 1
|
| 887 |
+
else:
|
| 888 |
+
self.positive_mining_stats["easy"] += 1
|
| 889 |
+
|
| 890 |
+
# Mine hard negatives
|
| 891 |
+
for dn in d_negative:
|
| 892 |
+
if dn <= hard_neg_threshold:
|
| 893 |
+
self.negative_mining_stats["hard"] += 1
|
| 894 |
+
self.hard_negative_count += 1
|
| 895 |
+
elif dn <= semi_neg_threshold:
|
| 896 |
+
self.negative_mining_stats["semi"] += 1
|
| 897 |
+
else:
|
| 898 |
+
self.negative_mining_stats["easy"] += 1
|
| 899 |
+
|
| 900 |
+
def _fixed_mining(self, d_positive: np.ndarray, d_negative: np.ndarray, margin: float):
|
| 901 |
+
"""Fallback fixed mining for early epochs."""
|
| 902 |
+
# Fixed thresholds
|
| 903 |
+
hard_pos_threshold = 0.5 # Far genuine pairs
|
| 904 |
+
hard_neg_threshold = 0.3 # Close impostor pairs
|
| 905 |
+
|
| 906 |
+
for dp in d_positive:
|
| 907 |
+
if dp >= hard_pos_threshold:
|
| 908 |
+
self.positive_mining_stats["hard"] += 1
|
| 909 |
+
self.hard_positive_count += 1
|
| 910 |
+
elif dp >= hard_pos_threshold * 0.7:
|
| 911 |
+
self.positive_mining_stats["semi"] += 1
|
| 912 |
+
else:
|
| 913 |
+
self.positive_mining_stats["easy"] += 1
|
| 914 |
+
|
| 915 |
+
for dn in d_negative:
|
| 916 |
+
if dn <= hard_neg_threshold:
|
| 917 |
+
self.negative_mining_stats["hard"] += 1
|
| 918 |
+
self.hard_negative_count += 1
|
| 919 |
+
elif dn <= hard_neg_threshold * 1.5:
|
| 920 |
+
self.negative_mining_stats["semi"] += 1
|
| 921 |
+
else:
|
| 922 |
+
self.negative_mining_stats["easy"] += 1
|
| 923 |
+
|
| 924 |
+
def get_mining_percentages(self) -> Dict[str, float]:
|
| 925 |
+
"""Get mining statistics as percentages with debugging info."""
|
| 926 |
+
total_pos = sum(self.positive_mining_stats.values())
|
| 927 |
+
total_neg = sum(self.negative_mining_stats.values())
|
| 928 |
+
|
| 929 |
+
percentages = {}
|
| 930 |
+
|
| 931 |
+
# Positive pair mining stats
|
| 932 |
+
if total_pos > 0:
|
| 933 |
+
percentages.update({
|
| 934 |
+
"pos_mining_easy_pct": 100.0 * self.positive_mining_stats["easy"] / total_pos,
|
| 935 |
+
"pos_mining_semi_pct": 100.0 * self.positive_mining_stats["semi"] / total_pos,
|
| 936 |
+
"pos_mining_hard_pct": 100.0 * self.positive_mining_stats["hard"] / total_pos,
|
| 937 |
+
})
|
| 938 |
+
else:
|
| 939 |
+
percentages.update({
|
| 940 |
+
"pos_mining_easy_pct": 0.0,
|
| 941 |
+
"pos_mining_semi_pct": 0.0,
|
| 942 |
+
"pos_mining_hard_pct": 0.0,
|
| 943 |
+
})
|
| 944 |
+
|
| 945 |
+
# Negative pair mining stats
|
| 946 |
+
if total_neg > 0:
|
| 947 |
+
percentages.update({
|
| 948 |
+
"neg_mining_easy_pct": 100.0 * self.negative_mining_stats["easy"] / total_neg,
|
| 949 |
+
"neg_mining_semi_pct": 100.0 * self.negative_mining_stats["semi"] / total_neg,
|
| 950 |
+
"neg_mining_hard_pct": 100.0 * self.negative_mining_stats["hard"] / total_neg,
|
| 951 |
+
})
|
| 952 |
+
else:
|
| 953 |
+
percentages.update({
|
| 954 |
+
"neg_mining_easy_pct": 0.0,
|
| 955 |
+
"neg_mining_semi_pct": 0.0,
|
| 956 |
+
"neg_mining_hard_pct": 0.0,
|
| 957 |
+
})
|
| 958 |
+
|
| 959 |
+
# Overall hard sample ratios
|
| 960 |
+
if self.total_positive_pairs > 0:
|
| 961 |
+
percentages["hard_positive_ratio"] = 100.0 * self.hard_positive_count / self.total_positive_pairs
|
| 962 |
+
else:
|
| 963 |
+
percentages["hard_positive_ratio"] = 0.0
|
| 964 |
+
|
| 965 |
+
if self.total_negative_pairs > 0:
|
| 966 |
+
percentages["hard_negative_ratio"] = 100.0 * self.hard_negative_count / self.total_negative_pairs
|
| 967 |
+
else:
|
| 968 |
+
percentages["hard_negative_ratio"] = 0.0
|
| 969 |
+
|
| 970 |
+
# False positive/negative rates
|
| 971 |
+
total_samples = self.total_positive_pairs + self.total_negative_pairs
|
| 972 |
+
if total_samples > 0:
|
| 973 |
+
percentages["false_positive_rate"] = 100.0 * self.false_positive_count / self.total_negative_pairs if self.total_negative_pairs > 0 else 0.0
|
| 974 |
+
percentages["false_negative_rate"] = 100.0 * self.false_negative_count / self.total_positive_pairs if self.total_positive_pairs > 0 else 0.0
|
| 975 |
+
|
| 976 |
+
# Add adaptive stats if available
|
| 977 |
+
if self.adaptive_stats:
|
| 978 |
+
percentages.update({
|
| 979 |
+
"adaptive_separation": self.adaptive_stats["separation"],
|
| 980 |
+
"adaptive_overlap": self.adaptive_stats["overlap_region"],
|
| 981 |
+
"adaptive_pos_spread": self.adaptive_stats["pos_std"],
|
| 982 |
+
"adaptive_neg_spread": self.adaptive_stats["neg_std"],
|
| 983 |
+
})
|
| 984 |
+
|
| 985 |
+
return percentages
|
| 986 |
+
|
| 987 |
+
def compute_separation_metrics(self) -> Dict[str, float]:
|
| 988 |
+
"""Compute distance separation metrics."""
|
| 989 |
+
if not self.genuine_distances or not self.forged_distances:
|
| 990 |
+
return {
|
| 991 |
+
"genuine_dist_mean": 0.0,
|
| 992 |
+
"forged_dist_mean": 0.0,
|
| 993 |
+
"genuine_dist_std": 0.0,
|
| 994 |
+
"forged_dist_std": 0.0,
|
| 995 |
+
"separation": 0.0,
|
| 996 |
+
"overlap": 0.0,
|
| 997 |
+
"separation_ratio": 0.0,
|
| 998 |
+
"cohesion_ratio": 0.0
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
gen_mean = np.mean(self.genuine_distances)
|
| 1002 |
+
forg_mean = np.mean(self.forged_distances)
|
| 1003 |
+
gen_std = np.std(self.genuine_distances)
|
| 1004 |
+
forg_std = np.std(self.forged_distances)
|
| 1005 |
+
|
| 1006 |
+
separation = forg_mean - gen_mean
|
| 1007 |
+
overlap = max(0, gen_mean + 2*gen_std - (forg_mean - 2*forg_std))
|
| 1008 |
+
|
| 1009 |
+
# Cohesion ratio: how tight are genuine pairs relative to separation
|
| 1010 |
+
cohesion_ratio = gen_std / (separation + 1e-8)
|
| 1011 |
+
|
| 1012 |
+
return {
|
| 1013 |
+
"genuine_dist_mean": gen_mean,
|
| 1014 |
+
"forged_dist_mean": forg_mean,
|
| 1015 |
+
"genuine_dist_std": gen_std,
|
| 1016 |
+
"forged_dist_std": forg_std,
|
| 1017 |
+
"separation": separation,
|
| 1018 |
+
"overlap": overlap,
|
| 1019 |
+
"separation_ratio": separation / (gen_std + forg_std + 1e-8),
|
| 1020 |
+
"cohesion_ratio": cohesion_ratio
|
| 1021 |
+
}
|
| 1022 |
+
|
| 1023 |
+
# ----------------------------
|
| 1024 |
+
# Enhanced Training Loop
|
| 1025 |
+
# ----------------------------
|
| 1026 |
+
class SignatureTrainer:
|
| 1027 |
+
"""Research-grade signature verification trainer."""
|
| 1028 |
+
|
| 1029 |
+
def __init__(self, config: TrainingConfig = CONFIG):
|
| 1030 |
+
self.config = config
|
| 1031 |
+
self.device = torch.device(config.device)
|
| 1032 |
+
|
| 1033 |
+
# Initialize managers
|
| 1034 |
+
self.mlflow_manager = MLFlowManager(tracking_uri=self.config.tracking_uri)
|
| 1035 |
+
self.curriculum_manager = CurriculumLearningManager(config)
|
| 1036 |
+
|
| 1037 |
+
# Training state
|
| 1038 |
+
self.current_epoch = 0
|
| 1039 |
+
self.best_eer = float('inf')
|
| 1040 |
+
self.patience_counter = 0
|
| 1041 |
+
self.global_step = 0
|
| 1042 |
+
|
| 1043 |
+
# Setup logging
|
| 1044 |
+
self._setup_logging()
|
| 1045 |
+
|
| 1046 |
+
def _setup_logging(self):
|
| 1047 |
+
"""Setup comprehensive logging."""
|
| 1048 |
+
logging.basicConfig(
|
| 1049 |
+
level=logging.INFO,
|
| 1050 |
+
format='%(asctime)s - %(levelname)s - %(message)s',
|
| 1051 |
+
handlers=[
|
| 1052 |
+
logging.FileHandler('training.log'),
|
| 1053 |
+
logging.StreamHandler()
|
| 1054 |
+
]
|
| 1055 |
+
)
|
| 1056 |
+
self.logger = logging.getLogger(__name__)
|
| 1057 |
+
|
| 1058 |
+
def _prepare_datasets(self) -> Tuple[SignatureDataset, SignatureDataset]:
|
| 1059 |
+
"""Prepare training and validation datasets."""
|
| 1060 |
+
# Load datasets
|
| 1061 |
+
train_data = pd.read_excel("../../data/classify/preprared_data/labels/train_triplets_balanced_v12.xlsx")
|
| 1062 |
+
val_data = pd.read_excel("../../data/classify/preprared_data/labels/valid_pairs_balanced_v12.xlsx")
|
| 1063 |
+
|
| 1064 |
+
train_dataset = SignatureDataset(
|
| 1065 |
+
folder_img="../../data/classify/preprared_data/images/",
|
| 1066 |
+
excel_data=train_data,
|
| 1067 |
+
curriculum_manager=self.curriculum_manager,
|
| 1068 |
+
is_train=True,
|
| 1069 |
+
config=self.config
|
| 1070 |
+
)
|
| 1071 |
+
|
| 1072 |
+
val_dataset = SignatureDataset(
|
| 1073 |
+
folder_img="../../data/classify/preprared_data/images/",
|
| 1074 |
+
excel_data=val_data,
|
| 1075 |
+
curriculum_manager=self.curriculum_manager,
|
| 1076 |
+
is_train=False,
|
| 1077 |
+
config=self.config
|
| 1078 |
+
)
|
| 1079 |
+
|
| 1080 |
+
self.logger.info(f"Training samples: {len(train_dataset)}")
|
| 1081 |
+
self.logger.info(f"Validation samples: {len(val_dataset)}")
|
| 1082 |
+
|
| 1083 |
+
return train_dataset, val_dataset
|
| 1084 |
+
|
| 1085 |
+
def _compute_precision_optimized_threshold(self, distances: np.ndarray,
|
| 1086 |
+
labels: np.ndarray,
|
| 1087 |
+
target_precision: float = None) -> float:
|
| 1088 |
+
"""Find threshold that achieves target precision while maximizing F1."""
|
| 1089 |
+
if target_precision is None:
|
| 1090 |
+
target_precision = self.config.target_precision
|
| 1091 |
+
|
| 1092 |
+
thresholds = np.linspace(distances.min(), distances.max(), 1000)
|
| 1093 |
+
best_threshold = thresholds[0]
|
| 1094 |
+
best_f1 = 0
|
| 1095 |
+
best_precision = 0
|
| 1096 |
+
best_recall = 0
|
| 1097 |
+
|
| 1098 |
+
for thresh in thresholds:
|
| 1099 |
+
predictions = (distances < thresh).astype(int)
|
| 1100 |
+
|
| 1101 |
+
# Calculate metrics
|
| 1102 |
+
tp = np.sum((predictions == 1) & (labels == 1))
|
| 1103 |
+
fp = np.sum((predictions == 1) & (labels == 0))
|
| 1104 |
+
fn = np.sum((predictions == 0) & (labels == 1))
|
| 1105 |
+
|
| 1106 |
+
precision = tp / (tp + fp + 1e-8)
|
| 1107 |
+
recall = tp / (tp + fn + 1e-8)
|
| 1108 |
+
f1 = 2 * precision * recall / (precision + recall + 1e-8)
|
| 1109 |
+
|
| 1110 |
+
# Prioritize precision while maintaining reasonable recall
|
| 1111 |
+
if precision >= target_precision and f1 > best_f1:
|
| 1112 |
+
best_f1 = f1
|
| 1113 |
+
best_threshold = thresh
|
| 1114 |
+
best_precision = precision
|
| 1115 |
+
best_recall = recall
|
| 1116 |
+
# If we can't achieve target precision, get best precision with recall > 0.5
|
| 1117 |
+
elif precision > best_precision and recall > 0.5:
|
| 1118 |
+
best_f1 = f1
|
| 1119 |
+
best_threshold = thresh
|
| 1120 |
+
best_precision = precision
|
| 1121 |
+
best_recall = recall
|
| 1122 |
+
|
| 1123 |
+
print(f" Precision-optimized threshold: {best_threshold:.4f} "
|
| 1124 |
+
f"(P: {best_precision:.3f}, R: {best_recall:.3f}, F1: {best_f1:.3f})")
|
| 1125 |
+
|
| 1126 |
+
return best_threshold
|
| 1127 |
+
|
| 1128 |
+
def _setup_model_and_optimizer(self) -> Tuple[SiameseSignatureNetwork, torch.optim.Optimizer, Any]:
|
| 1129 |
+
"""Setup model, optimizer, and scheduler."""
|
| 1130 |
+
# Initialize model
|
| 1131 |
+
model = SiameseSignatureNetwork(self.config)
|
| 1132 |
+
|
| 1133 |
+
# Compile model if available
|
| 1134 |
+
if hasattr(torch, "compile") and platform.system() != "Windows":
|
| 1135 |
+
self.logger.info("Compiling model with torch.compile")
|
| 1136 |
+
model = torch.compile(model)
|
| 1137 |
+
|
| 1138 |
+
model = model.to(self.device)
|
| 1139 |
+
|
| 1140 |
+
# Count parameters
|
| 1141 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 1142 |
+
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 1143 |
+
self.logger.info(f"Total parameters: {total_params:,}")
|
| 1144 |
+
self.logger.info(f"Trainable parameters: {trainable_params:,}")
|
| 1145 |
+
|
| 1146 |
+
# Setup optimizer with parameter groups
|
| 1147 |
+
param_groups = model.get_parameter_groups()
|
| 1148 |
+
optimizer = torch.optim.AdamW(param_groups)
|
| 1149 |
+
|
| 1150 |
+
# Log learning rates
|
| 1151 |
+
for group in param_groups:
|
| 1152 |
+
self.logger.info(f"Parameter group '{group['name']}': LR = {group['lr']:.2e}")
|
| 1153 |
+
|
| 1154 |
+
# Setup scheduler
|
| 1155 |
+
if self.config.lr_scheduler == "cosine":
|
| 1156 |
+
scheduler = CosineAnnealingLR(optimizer, T_max=self.config.max_epochs)
|
| 1157 |
+
else:
|
| 1158 |
+
scheduler = ReduceLROnPlateau(optimizer, mode='min', patience=5, factor=0.5)
|
| 1159 |
+
|
| 1160 |
+
return model, optimizer, scheduler
|
| 1161 |
+
|
| 1162 |
+
def _setup_checkpoint_management(self, run_id: str) -> Tuple[str, str]:
|
| 1163 |
+
"""Setup checkpoint directories."""
|
| 1164 |
+
checkpoint_dir = os.path.join("../../model/models_checkpoints/", run_id)
|
| 1165 |
+
figures_dir = os.path.join(checkpoint_dir, "figures")
|
| 1166 |
+
os.makedirs(checkpoint_dir, exist_ok=True)
|
| 1167 |
+
os.makedirs(figures_dir, exist_ok=True)
|
| 1168 |
+
return checkpoint_dir, figures_dir
|
| 1169 |
+
|
| 1170 |
+
def _load_checkpoint(self, model: nn.Module, optimizer: torch.optim.Optimizer,
|
| 1171 |
+
scheduler: Any, scaler: torch.amp.GradScaler) -> int:
|
| 1172 |
+
"""Load checkpoint if specified."""
|
| 1173 |
+
if not self.config.last_epoch_weights:
|
| 1174 |
+
return 1
|
| 1175 |
+
|
| 1176 |
+
checkpoint_path = self.config.last_epoch_weights
|
| 1177 |
+
self.logger.info(f"Loading checkpoint from {checkpoint_path}")
|
| 1178 |
+
|
| 1179 |
+
try:
|
| 1180 |
+
checkpoint = torch.load(checkpoint_path, map_location=self.device, weights_only=False)
|
| 1181 |
+
|
| 1182 |
+
model.load_state_dict(checkpoint["model_state_dict"])
|
| 1183 |
+
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
|
| 1184 |
+
scheduler.load_state_dict(checkpoint["scheduler_state_dict"])
|
| 1185 |
+
scaler.load_state_dict(checkpoint.get("scaler_state_dict", scaler.state_dict()))
|
| 1186 |
+
|
| 1187 |
+
start_epoch = checkpoint["epoch"] + 1
|
| 1188 |
+
self.best_eer = checkpoint.get("best_eer", self.best_eer)
|
| 1189 |
+
model.distance_threshold = checkpoint.get("prediction_threshold", 0.5)
|
| 1190 |
+
|
| 1191 |
+
self.logger.info(f"Resumed from epoch {start_epoch}, best EER: {self.best_eer:.4f}")
|
| 1192 |
+
return start_epoch
|
| 1193 |
+
|
| 1194 |
+
except Exception as e:
|
| 1195 |
+
self.logger.error(f"Failed to load checkpoint: {e}")
|
| 1196 |
+
return 1
|
| 1197 |
+
|
| 1198 |
+
def train_epoch(self, model: nn.Module, train_loader: DataLoader,
|
| 1199 |
+
optimizer: torch.optim.Optimizer, scaler: torch.amp.GradScaler,
|
| 1200 |
+
epoch: int) -> TrainingMetrics:
|
| 1201 |
+
"""Enhanced training with intelligent adaptive mining for both hard positives and negatives."""
|
| 1202 |
+
model.train()
|
| 1203 |
+
metrics = TrainingMetrics()
|
| 1204 |
+
|
| 1205 |
+
curriculum_stats = train_loader.dataset.get_curriculum_stats()
|
| 1206 |
+
|
| 1207 |
+
# INTELLIGENT MARGIN CALCULATION
|
| 1208 |
+
base_margin = 0.5 # Base margin for normalized embeddings
|
| 1209 |
+
margin_multiplier = curriculum_stats["margin_multiplier"]
|
| 1210 |
+
adaptive_margin = base_margin * margin_multiplier
|
| 1211 |
+
|
| 1212 |
+
# Progressive margin adjustment based on epoch
|
| 1213 |
+
epoch_progress = epoch / self.config.max_epochs
|
| 1214 |
+
progressive_factor = 1.2 - 0.4 * epoch_progress # 1.2 → 0.8
|
| 1215 |
+
final_margin = adaptive_margin * progressive_factor
|
| 1216 |
+
|
| 1217 |
+
# Tracking counters
|
| 1218 |
+
forgery_batch_count = 0
|
| 1219 |
+
genuine_batch_count = 0
|
| 1220 |
+
batch_fp_count = 0
|
| 1221 |
+
batch_fn_count = 0
|
| 1222 |
+
|
| 1223 |
+
# Debug info
|
| 1224 |
+
debug_printed = False
|
| 1225 |
+
|
| 1226 |
+
pbar = tqdm(train_loader, desc=f"[Train] Epoch {epoch}")
|
| 1227 |
+
|
| 1228 |
+
for step, (anchors, positives, negatives, anchor_ids, negative_ids) in enumerate(pbar):
|
| 1229 |
+
|
| 1230 |
+
# Move to device
|
| 1231 |
+
anchors = anchors.to(self.device, non_blocking=True)
|
| 1232 |
+
positives = positives.to(self.device, non_blocking=True)
|
| 1233 |
+
negatives = negatives.to(self.device, non_blocking=True)
|
| 1234 |
+
anchor_ids = anchor_ids.to(self.device, non_blocking=True)
|
| 1235 |
+
negative_ids = negative_ids.to(self.device, non_blocking=True)
|
| 1236 |
+
|
| 1237 |
+
# Count forgery vs genuine negatives
|
| 1238 |
+
max_genuine_id = len(train_loader.dataset.genuine_id_mapping)
|
| 1239 |
+
forgery_mask = negative_ids >= max_genuine_id + 100
|
| 1240 |
+
forgery_batch_count += forgery_mask.sum().item()
|
| 1241 |
+
genuine_batch_count += (~forgery_mask).sum().item()
|
| 1242 |
+
|
| 1243 |
+
if not debug_printed and step == 0:
|
| 1244 |
+
print(f"\n[DEBUG Epoch {epoch}]")
|
| 1245 |
+
print(f" Final margin: {final_margin:.3f}")
|
| 1246 |
+
print(f" Hard negative ratio target: {curriculum_stats['hard_negative_ratio']:.3f}")
|
| 1247 |
+
print(f" Hard positive ratio target: {curriculum_stats['hard_positive_ratio']:.3f}")
|
| 1248 |
+
print(f" Negative weight multiplier: {self.config.negative_weight_multiplier:.2f}")
|
| 1249 |
+
print(f" Triplet weight: {self.config.triplet_weight:.2f}")
|
| 1250 |
+
print(f" FP penalty weight: {self.config.false_positive_penalty_weight:.2f}")
|
| 1251 |
+
debug_printed = True
|
| 1252 |
+
|
| 1253 |
+
# Forward pass to get embeddings first
|
| 1254 |
+
with torch.amp.autocast(device_type=self.device.type):
|
| 1255 |
+
a_emb, p_emb, n_emb = model(anchors, positives, negatives)
|
| 1256 |
+
|
| 1257 |
+
# Compute distances and weights BEFORE loss computation
|
| 1258 |
+
with torch.no_grad():
|
| 1259 |
+
d_ap = F.pairwise_distance(a_emb, p_emb).cpu().numpy()
|
| 1260 |
+
d_an = F.pairwise_distance(a_emb, n_emb).cpu().numpy()
|
| 1261 |
+
|
| 1262 |
+
# Get precision-focused weights
|
| 1263 |
+
pos_weights, neg_weights = metrics.compute_precision_focused_weights(
|
| 1264 |
+
d_ap, d_an,
|
| 1265 |
+
negative_weight_multiplier=self.config.negative_weight_multiplier
|
| 1266 |
+
)
|
| 1267 |
+
pos_weights = pos_weights.to(self.device)
|
| 1268 |
+
neg_weights = neg_weights.to(self.device)
|
| 1269 |
+
|
| 1270 |
+
# Track false positives/negatives
|
| 1271 |
+
fp_mask = d_an < model.distance_threshold
|
| 1272 |
+
fn_mask = d_ap > model.distance_threshold
|
| 1273 |
+
batch_fp_count = fp_mask.sum()
|
| 1274 |
+
batch_fn_count = fn_mask.sum()
|
| 1275 |
+
metrics.false_positive_count += batch_fp_count
|
| 1276 |
+
metrics.false_negative_count += batch_fn_count
|
| 1277 |
+
|
| 1278 |
+
# Prepare distance weights for loss
|
| 1279 |
+
distance_weights = {
|
| 1280 |
+
'positive_weights': pos_weights,
|
| 1281 |
+
'negative_weights': neg_weights
|
| 1282 |
+
}
|
| 1283 |
+
|
| 1284 |
+
# Now compute loss with weights
|
| 1285 |
+
with torch.amp.autocast(device_type=self.device.type):
|
| 1286 |
+
all_embeddings = torch.cat([a_emb, p_emb, n_emb], dim=0)
|
| 1287 |
+
all_labels = torch.cat([anchor_ids, anchor_ids, negative_ids], dim=0)
|
| 1288 |
+
|
| 1289 |
+
# Compute loss with triplet component and distance weights
|
| 1290 |
+
batch_loss = model.compute_loss(
|
| 1291 |
+
all_embeddings, all_labels,
|
| 1292 |
+
anchors=a_emb, positives=p_emb, negatives=n_emb,
|
| 1293 |
+
distance_weights=distance_weights
|
| 1294 |
+
)
|
| 1295 |
+
|
| 1296 |
+
# Gradient accumulation
|
| 1297 |
+
loss = batch_loss / self.config.grad_accum_steps
|
| 1298 |
+
scaler.scale(loss).backward()
|
| 1299 |
+
|
| 1300 |
+
if (step + 1) % self.config.grad_accum_steps == 0 or (step + 1) == len(train_loader):
|
| 1301 |
+
scaler.unscale_(optimizer)
|
| 1302 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
| 1303 |
+
scaler.step(optimizer)
|
| 1304 |
+
scaler.update()
|
| 1305 |
+
optimizer.zero_grad(set_to_none=True)
|
| 1306 |
+
self.global_step += 1
|
| 1307 |
+
|
| 1308 |
+
# Update metrics
|
| 1309 |
+
metrics.losses.append(batch_loss.item())
|
| 1310 |
+
metrics.genuine_distances.extend(d_ap.tolist())
|
| 1311 |
+
metrics.forged_distances.extend(d_an.tolist())
|
| 1312 |
+
|
| 1313 |
+
# Use enhanced mining with difficulty parameters
|
| 1314 |
+
metrics.update_mining_stats(d_ap, d_an, final_margin, curriculum_stats)
|
| 1315 |
+
|
| 1316 |
+
# Store learning rates
|
| 1317 |
+
for i, group in enumerate(optimizer.param_groups):
|
| 1318 |
+
metrics.learning_rates[f"lr_{group.get('name', i)}"] = group['lr']
|
| 1319 |
+
|
| 1320 |
+
# Enhanced progress bar with precision focus
|
| 1321 |
+
sep = np.mean(d_an) - np.mean(d_ap)
|
| 1322 |
+
actual_forgery_ratio = forgery_batch_count / (forgery_batch_count + genuine_batch_count) if (forgery_batch_count + genuine_batch_count) > 0 else 0
|
| 1323 |
+
|
| 1324 |
+
# Get current mining stats
|
| 1325 |
+
mining_pcts = metrics.get_mining_percentages()
|
| 1326 |
+
|
| 1327 |
+
pbar.set_postfix({
|
| 1328 |
+
"loss": f"{batch_loss.item():.3f}",
|
| 1329 |
+
"h_neg%": f"{mining_pcts.get('neg_mining_hard_pct', 0):.0f}",
|
| 1330 |
+
"h_pos%": f"{mining_pcts.get('pos_mining_hard_pct', 0):.0f}",
|
| 1331 |
+
"d_sep": f"{sep:.3f}",
|
| 1332 |
+
"FP": f"{batch_fp_count}",
|
| 1333 |
+
"FN": f"{batch_fn_count}",
|
| 1334 |
+
"margin": f"{final_margin:.3f}"
|
| 1335 |
+
})
|
| 1336 |
+
|
| 1337 |
+
# Periodic logging
|
| 1338 |
+
if self.global_step % self.config.log_frequency == 0:
|
| 1339 |
+
enhanced_stats = {
|
| 1340 |
+
**curriculum_stats,
|
| 1341 |
+
**mining_pcts,
|
| 1342 |
+
"actual_forgery_ratio": actual_forgery_ratio,
|
| 1343 |
+
"batch_false_positives": int(batch_fp_count),
|
| 1344 |
+
"batch_false_negatives": int(batch_fn_count),
|
| 1345 |
+
"final_margin": final_margin,
|
| 1346 |
+
"epoch_progress": epoch_progress
|
| 1347 |
+
}
|
| 1348 |
+
self._log_training_step(metrics, enhanced_stats, self.global_step)
|
| 1349 |
+
|
| 1350 |
+
# Memory cleanup
|
| 1351 |
+
del anchors, positives, negatives, a_emb, p_emb, n_emb
|
| 1352 |
+
torch.cuda.empty_cache()
|
| 1353 |
+
|
| 1354 |
+
# End-of-epoch mining summary
|
| 1355 |
+
mining_pcts = metrics.get_mining_percentages()
|
| 1356 |
+
print(f"\n[Epoch {epoch} Mining Summary]")
|
| 1357 |
+
print(f" Hard Negatives: {mining_pcts.get('neg_mining_hard_pct', 0):.1f}% | Semi: {mining_pcts.get('neg_mining_semi_pct', 0):.1f}% | Easy: {mining_pcts.get('neg_mining_easy_pct', 0):.1f}%")
|
| 1358 |
+
print(f" Hard Positives: {mining_pcts.get('pos_mining_hard_pct', 0):.1f}% | Semi: {mining_pcts.get('pos_mining_semi_pct', 0):.1f}% | Easy: {mining_pcts.get('pos_mining_easy_pct', 0):.1f}%")
|
| 1359 |
+
print(f" Overall Hard Ratios - Positives: {mining_pcts.get('hard_positive_ratio', 0):.1f}% | Negatives: {mining_pcts.get('hard_negative_ratio', 0):.1f}%")
|
| 1360 |
+
print(f" False Positive Rate: {mining_pcts.get('false_positive_rate', 0):.1f}% | False Negative Rate: {mining_pcts.get('false_negative_rate', 0):.1f}%")
|
| 1361 |
+
|
| 1362 |
+
if "adaptive_separation" in mining_pcts:
|
| 1363 |
+
print(f" Adaptive separation: {mining_pcts['adaptive_separation']:.3f} | Overlap: {mining_pcts['adaptive_overlap']:.3f}")
|
| 1364 |
+
|
| 1365 |
+
return metrics
|
| 1366 |
+
|
| 1367 |
+
def validate_epoch(self, model: nn.Module, val_loader: DataLoader,
|
| 1368 |
+
epoch: int) -> Tuple[float, float, Dict[str, float]]:
|
| 1369 |
+
"""Validate for one epoch."""
|
| 1370 |
+
model.eval()
|
| 1371 |
+
|
| 1372 |
+
val_distances = []
|
| 1373 |
+
val_labels = []
|
| 1374 |
+
val_embeddings = []
|
| 1375 |
+
val_person_ids = []
|
| 1376 |
+
val_loss_total = 0.0
|
| 1377 |
+
|
| 1378 |
+
with torch.no_grad():
|
| 1379 |
+
pbar = tqdm(val_loader, desc=f"[Val] Epoch {epoch}")
|
| 1380 |
+
|
| 1381 |
+
for img1, img2, labels, id1, id2 in pbar:
|
| 1382 |
+
# Move to device
|
| 1383 |
+
img1 = img1.to(self.device, non_blocking=True)
|
| 1384 |
+
img2 = img2.to(self.device, non_blocking=True)
|
| 1385 |
+
labels = labels.to(self.device, non_blocking=True)
|
| 1386 |
+
id1 = id1.to(self.device, non_blocking=True)
|
| 1387 |
+
id2 = id2.to(self.device, non_blocking=True)
|
| 1388 |
+
|
| 1389 |
+
# Forward pass
|
| 1390 |
+
emb1, emb2 = model(img1, img2)
|
| 1391 |
+
distances = F.pairwise_distance(emb1, emb2)
|
| 1392 |
+
|
| 1393 |
+
# Compute validation loss
|
| 1394 |
+
val_loss = self._compute_validation_loss(emb1, emb2, labels, id1, id2, model.criterion)
|
| 1395 |
+
val_loss_total += val_loss.item()
|
| 1396 |
+
|
| 1397 |
+
# Collect results
|
| 1398 |
+
val_distances.extend(distances.cpu().numpy())
|
| 1399 |
+
val_labels.extend(labels.cpu().numpy())
|
| 1400 |
+
val_embeddings.append(emb1.cpu().numpy())
|
| 1401 |
+
val_embeddings.append(emb2.cpu().numpy())
|
| 1402 |
+
val_person_ids.extend(id1.cpu().numpy())
|
| 1403 |
+
val_person_ids.extend(id2.cpu().numpy())
|
| 1404 |
+
|
| 1405 |
+
# Update progress
|
| 1406 |
+
pos_mask = labels == 1
|
| 1407 |
+
neg_mask = labels == 0
|
| 1408 |
+
pos_dist = distances[pos_mask].mean().item() if pos_mask.any() else 0.0
|
| 1409 |
+
neg_dist = distances[neg_mask].mean().item() if neg_mask.any() else 0.0
|
| 1410 |
+
|
| 1411 |
+
pbar.set_postfix({
|
| 1412 |
+
"loss": f"{val_loss.item():.4f}",
|
| 1413 |
+
"d_pos": f"{pos_dist:.3f}",
|
| 1414 |
+
"d_neg": f"{neg_dist:.3f}",
|
| 1415 |
+
"sep": f"{neg_dist - pos_dist:.3f}"
|
| 1416 |
+
})
|
| 1417 |
+
|
| 1418 |
+
# Memory cleanup
|
| 1419 |
+
del img1, img2, emb1, emb2
|
| 1420 |
+
torch.cuda.empty_cache()
|
| 1421 |
+
|
| 1422 |
+
# Process results
|
| 1423 |
+
val_distances = np.array(val_distances)
|
| 1424 |
+
val_labels = np.array(val_labels)
|
| 1425 |
+
val_embeddings = np.concatenate(val_embeddings)
|
| 1426 |
+
val_person_ids = np.array(val_person_ids)
|
| 1427 |
+
avg_val_loss = val_loss_total / len(val_loader)
|
| 1428 |
+
|
| 1429 |
+
# Compute metrics
|
| 1430 |
+
threshold, eer, metrics_dict = self._compute_validation_metrics(
|
| 1431 |
+
val_distances, val_labels, avg_val_loss
|
| 1432 |
+
)
|
| 1433 |
+
|
| 1434 |
+
# Update model threshold
|
| 1435 |
+
model.distance_threshold = threshold
|
| 1436 |
+
|
| 1437 |
+
return threshold, eer, {
|
| 1438 |
+
"metrics": metrics_dict,
|
| 1439 |
+
"embeddings": val_embeddings,
|
| 1440 |
+
"labels": np.repeat(val_labels, 2),
|
| 1441 |
+
"person_ids": val_person_ids,
|
| 1442 |
+
"distances": np.repeat(val_distances, 2)
|
| 1443 |
+
}
|
| 1444 |
+
|
| 1445 |
+
def _compute_validation_loss(self, emb1: torch.Tensor, emb2: torch.Tensor,
|
| 1446 |
+
binary_labels: torch.Tensor, person_ids1: torch.Tensor,
|
| 1447 |
+
person_ids2: torch.Tensor, criterion) -> torch.Tensor:
|
| 1448 |
+
"""Compute validation loss using MultiSimilarityLoss."""
|
| 1449 |
+
labels1 = person_ids1.clone()
|
| 1450 |
+
labels2 = person_ids2.clone()
|
| 1451 |
+
|
| 1452 |
+
# Handle forged pairs
|
| 1453 |
+
forged_mask = binary_labels == 0
|
| 1454 |
+
if forged_mask.any():
|
| 1455 |
+
max_person_id = torch.max(torch.cat([person_ids1, person_ids2])).item()
|
| 1456 |
+
labels2[forged_mask] = labels2[forged_mask] + max_person_id + 1
|
| 1457 |
+
|
| 1458 |
+
# Handle genuine pairs
|
| 1459 |
+
genuine_mask = binary_labels == 1
|
| 1460 |
+
labels2[genuine_mask] = labels1[genuine_mask]
|
| 1461 |
+
|
| 1462 |
+
# Combine embeddings and labels
|
| 1463 |
+
all_embeddings = torch.cat([emb1, emb2], dim=0)
|
| 1464 |
+
all_labels = torch.cat([labels1, labels2], dim=0)
|
| 1465 |
+
|
| 1466 |
+
return criterion(all_embeddings, all_labels)
|
| 1467 |
+
|
| 1468 |
+
def _compute_validation_metrics(self, distances: np.ndarray, labels: np.ndarray,
|
| 1469 |
+
val_loss: float) -> Tuple[float, float, Dict[str, float]]:
|
| 1470 |
+
"""Compute comprehensive validation metrics with precision focus."""
|
| 1471 |
+
# Compute EER and threshold
|
| 1472 |
+
similarity_scores = 1.0 / (distances + 1e-8)
|
| 1473 |
+
fpr, tpr, thresholds = roc_curve(labels, similarity_scores, pos_label=1)
|
| 1474 |
+
fnr = 1 - tpr
|
| 1475 |
+
eer_idx = np.nanargmin(np.abs(fpr - fnr))
|
| 1476 |
+
eer = fpr[eer_idx]
|
| 1477 |
+
eer_threshold = 1.0 / thresholds[eer_idx]
|
| 1478 |
+
|
| 1479 |
+
# Get precision-optimized threshold
|
| 1480 |
+
precision_threshold = self._compute_precision_optimized_threshold(distances, labels)
|
| 1481 |
+
|
| 1482 |
+
# Use precision-optimized threshold instead of EER threshold
|
| 1483 |
+
threshold = precision_threshold
|
| 1484 |
+
|
| 1485 |
+
# Compute metrics with precision-optimized threshold
|
| 1486 |
+
predictions = (distances < threshold).astype(int)
|
| 1487 |
+
precision, recall, f1, _ = precision_recall_fscore_support(
|
| 1488 |
+
labels, predictions, average='binary', zero_division=0
|
| 1489 |
+
)
|
| 1490 |
+
accuracy = (predictions == labels).mean()
|
| 1491 |
+
roc_auc = auc(fpr, tpr)
|
| 1492 |
+
|
| 1493 |
+
# Distance statistics
|
| 1494 |
+
genuine_dist = np.mean([d for d, l in zip(distances, labels) if l == 1])
|
| 1495 |
+
forged_dist = np.mean([d for d, l in zip(distances, labels) if l == 0])
|
| 1496 |
+
separation = forged_dist - genuine_dist
|
| 1497 |
+
|
| 1498 |
+
# Confidence scores
|
| 1499 |
+
confidences = 1.0 / (distances + 1e-8)
|
| 1500 |
+
conf_genuine = np.mean([c for c, l in zip(confidences, labels) if l == 1])
|
| 1501 |
+
conf_forged = np.mean([c for c, l in zip(confidences, labels) if l == 0])
|
| 1502 |
+
|
| 1503 |
+
metrics_dict = {
|
| 1504 |
+
"val_loss": val_loss,
|
| 1505 |
+
"val_EER": eer,
|
| 1506 |
+
"val_f1": f1,
|
| 1507 |
+
"val_auc": roc_auc,
|
| 1508 |
+
"val_accuracy": accuracy,
|
| 1509 |
+
"val_precision": precision,
|
| 1510 |
+
"val_recall": recall,
|
| 1511 |
+
"val_separation": separation,
|
| 1512 |
+
"val_genuine_dist": genuine_dist,
|
| 1513 |
+
"val_forged_dist": forged_dist,
|
| 1514 |
+
"val_genuine_conf": conf_genuine,
|
| 1515 |
+
"val_forged_conf": conf_forged,
|
| 1516 |
+
"threshold": threshold,
|
| 1517 |
+
"eer_threshold": eer_threshold,
|
| 1518 |
+
"precision_threshold": precision_threshold
|
| 1519 |
+
}
|
| 1520 |
+
|
| 1521 |
+
return threshold, eer, metrics_dict
|
| 1522 |
+
|
| 1523 |
+
def _log_training_step(self, metrics: TrainingMetrics, curriculum_stats: Dict, step: int):
|
| 1524 |
+
"""Log training step metrics."""
|
| 1525 |
+
if not metrics.losses:
|
| 1526 |
+
return
|
| 1527 |
+
|
| 1528 |
+
try:
|
| 1529 |
+
# Compute separation metrics
|
| 1530 |
+
sep_metrics = metrics.compute_separation_metrics()
|
| 1531 |
+
|
| 1532 |
+
# Get mining percentages
|
| 1533 |
+
mining_percentages = metrics.get_mining_percentages()
|
| 1534 |
+
|
| 1535 |
+
# Log to MLflow
|
| 1536 |
+
log_dict = {
|
| 1537 |
+
"train_loss": np.mean(metrics.losses[-10:]), # Last 10 batches
|
| 1538 |
+
**sep_metrics,
|
| 1539 |
+
**mining_percentages,
|
| 1540 |
+
**curriculum_stats,
|
| 1541 |
+
**metrics.learning_rates
|
| 1542 |
+
}
|
| 1543 |
+
|
| 1544 |
+
mlflow.log_metrics(log_dict, step=step)
|
| 1545 |
+
|
| 1546 |
+
except Exception as e:
|
| 1547 |
+
print(f"Warning: Failed to log training step metrics: {e}")
|
| 1548 |
+
|
| 1549 |
+
def _log_epoch_metrics(self, train_metrics: TrainingMetrics, val_metrics: Dict, epoch: int):
|
| 1550 |
+
"""Log comprehensive epoch metrics."""
|
| 1551 |
+
try:
|
| 1552 |
+
# Training metrics
|
| 1553 |
+
train_sep = train_metrics.compute_separation_metrics()
|
| 1554 |
+
train_mining = train_metrics.get_mining_percentages()
|
| 1555 |
+
|
| 1556 |
+
log_dict = {
|
| 1557 |
+
"epoch": epoch,
|
| 1558 |
+
"train_loss_epoch": np.mean(train_metrics.losses),
|
| 1559 |
+
**train_sep,
|
| 1560 |
+
**train_mining,
|
| 1561 |
+
**val_metrics["metrics"],
|
| 1562 |
+
**train_metrics.learning_rates
|
| 1563 |
+
}
|
| 1564 |
+
|
| 1565 |
+
mlflow.log_metrics(log_dict, step=epoch)
|
| 1566 |
+
|
| 1567 |
+
# Log key metrics to console
|
| 1568 |
+
self.logger.info(f"Epoch {epoch}/{self.config.max_epochs} Summary:")
|
| 1569 |
+
self.logger.info(f" Train Loss: {log_dict['train_loss_epoch']:.4f}")
|
| 1570 |
+
self.logger.info(f" Val EER: {log_dict['val_EER']:.4f}")
|
| 1571 |
+
self.logger.info(f" Val F1: {log_dict['val_f1']:.4f}")
|
| 1572 |
+
self.logger.info(f" Separation: {log_dict['separation']:.4f}")
|
| 1573 |
+
|
| 1574 |
+
except Exception as e:
|
| 1575 |
+
self.logger.error(f"Failed to log epoch metrics: {e}")
|
| 1576 |
+
# Log minimal metrics as fallback
|
| 1577 |
+
mlflow.log_metrics({
|
| 1578 |
+
"epoch": epoch,
|
| 1579 |
+
"train_loss_epoch": np.mean(train_metrics.losses) if train_metrics.losses else 0.0,
|
| 1580 |
+
**val_metrics["metrics"]
|
| 1581 |
+
}, step=epoch)
|
| 1582 |
+
|
| 1583 |
+
def _save_checkpoint(self, model: nn.Module, optimizer: torch.optim.Optimizer,
|
| 1584 |
+
scheduler: Any, scaler: torch.amp.GradScaler, epoch: int,
|
| 1585 |
+
threshold: float, eer: float, checkpoint_dir: str, is_best: bool = False):
|
| 1586 |
+
"""Save model checkpoint."""
|
| 1587 |
+
checkpoint = {
|
| 1588 |
+
"epoch": epoch,
|
| 1589 |
+
"model_state_dict": model.state_dict(),
|
| 1590 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 1591 |
+
"scheduler_state_dict": scheduler.state_dict(),
|
| 1592 |
+
"scaler_state_dict": scaler.state_dict(),
|
| 1593 |
+
"prediction_threshold": threshold,
|
| 1594 |
+
"best_eer": self.best_eer,
|
| 1595 |
+
"eer": eer,
|
| 1596 |
+
"config": asdict(self.config)
|
| 1597 |
+
}
|
| 1598 |
+
|
| 1599 |
+
# Save regular checkpoint
|
| 1600 |
+
if epoch % self.config.save_frequency == 0:
|
| 1601 |
+
torch.save(checkpoint, os.path.join(checkpoint_dir, f"epoch_{epoch}.pth"))
|
| 1602 |
+
|
| 1603 |
+
# Save best checkpoint
|
| 1604 |
+
if is_best:
|
| 1605 |
+
torch.save(checkpoint, os.path.join(checkpoint_dir, "best_model.pth"))
|
| 1606 |
+
self.logger.info(f"New best model saved with EER: {eer:.4f}")
|
| 1607 |
+
|
| 1608 |
+
def _create_visualizations(self, val_results: Dict, epoch: int, figures_dir: str):
|
| 1609 |
+
"""Create comprehensive visualizations."""
|
| 1610 |
+
if epoch % self.config.visualize_frequency != 0:
|
| 1611 |
+
return
|
| 1612 |
+
|
| 1613 |
+
# Distance distribution plot
|
| 1614 |
+
self._plot_distance_distribution(
|
| 1615 |
+
val_results["distances"][:len(val_results["distances"])//2],
|
| 1616 |
+
val_results["labels"][:len(val_results["labels"])//2],
|
| 1617 |
+
epoch, figures_dir
|
| 1618 |
+
)
|
| 1619 |
+
|
| 1620 |
+
# t-SNE embedding visualization
|
| 1621 |
+
self._plot_tsne_embeddings(
|
| 1622 |
+
val_results["embeddings"],
|
| 1623 |
+
val_results["labels"],
|
| 1624 |
+
val_results["person_ids"],
|
| 1625 |
+
val_results["distances"],
|
| 1626 |
+
epoch, figures_dir
|
| 1627 |
+
)
|
| 1628 |
+
|
| 1629 |
+
def _plot_distance_distribution(self, distances: np.ndarray, labels: np.ndarray,
|
| 1630 |
+
epoch: int, figures_dir: str):
|
| 1631 |
+
"""Plot distance distribution."""
|
| 1632 |
+
genuine_dists = distances[labels == 1]
|
| 1633 |
+
forged_dists = distances[labels == 0]
|
| 1634 |
+
|
| 1635 |
+
plt.figure(figsize=(12, 8))
|
| 1636 |
+
plt.hist(genuine_dists, bins=50, alpha=0.6, color='blue',
|
| 1637 |
+
label=f'Genuine (μ={np.mean(genuine_dists):.4f}±{np.std(genuine_dists):.4f})')
|
| 1638 |
+
plt.hist(forged_dists, bins=50, alpha=0.6, color='red',
|
| 1639 |
+
label=f'Forged (μ={np.mean(forged_dists):.4f}±{np.std(forged_dists):.4f})')
|
| 1640 |
+
|
| 1641 |
+
separation = np.mean(forged_dists) - np.mean(genuine_dists)
|
| 1642 |
+
plt.axvline(np.mean(genuine_dists), color='blue', linestyle='--', alpha=0.7)
|
| 1643 |
+
plt.axvline(np.mean(forged_dists), color='red', linestyle='--', alpha=0.7)
|
| 1644 |
+
|
| 1645 |
+
plt.title(f'Distance Distribution - Epoch {epoch}\nSeparation: {separation:.4f}', fontsize=14)
|
| 1646 |
+
plt.xlabel('Embedding Distance', fontsize=12)
|
| 1647 |
+
plt.ylabel('Frequency', fontsize=12)
|
| 1648 |
+
plt.legend(fontsize=12)
|
| 1649 |
+
plt.grid(alpha=0.3)
|
| 1650 |
+
|
| 1651 |
+
plt.savefig(os.path.join(figures_dir, f"distance_dist_epoch_{epoch}.png"),
|
| 1652 |
+
dpi=150, bbox_inches='tight')
|
| 1653 |
+
plt.close()
|
| 1654 |
+
|
| 1655 |
+
|
| 1656 |
+
def _plot_tsne_embeddings(self, embeddings: np.ndarray, labels: np.ndarray,
|
| 1657 |
+
person_ids: np.ndarray, distances: np.ndarray,
|
| 1658 |
+
epoch: int, figures_dir: str, n_samples: int = 3000):
|
| 1659 |
+
"""Create comprehensive t-SNE visualization."""
|
| 1660 |
+
# Sample for computational efficiency
|
| 1661 |
+
if len(embeddings) > n_samples:
|
| 1662 |
+
indices = np.random.choice(len(embeddings), n_samples, replace=False)
|
| 1663 |
+
embeddings = embeddings[indices]
|
| 1664 |
+
labels = labels[indices]
|
| 1665 |
+
person_ids = person_ids[indices]
|
| 1666 |
+
distances = distances[indices]
|
| 1667 |
+
|
| 1668 |
+
# Compute t-SNE
|
| 1669 |
+
tsne = TSNE(n_components=2, random_state=42, perplexity=min(30, len(embeddings)-1))
|
| 1670 |
+
embeddings_2d = tsne.fit_transform(embeddings)
|
| 1671 |
+
|
| 1672 |
+
fig, axes = plt.subplots(1, 3, figsize=(20, 6))
|
| 1673 |
+
|
| 1674 |
+
# 1. Genuine vs Forged
|
| 1675 |
+
for label_val, color, name in [(0, 'red', 'Forged'), (1, 'blue', 'Genuine')]:
|
| 1676 |
+
mask = labels == label_val
|
| 1677 |
+
if mask.any():
|
| 1678 |
+
axes[0].scatter(embeddings_2d[mask, 0], embeddings_2d[mask, 1],
|
| 1679 |
+
c=color, label=name, alpha=0.6, s=20)
|
| 1680 |
+
axes[0].set_title(f'Genuine vs Forged - Epoch {epoch}')
|
| 1681 |
+
axes[0].legend()
|
| 1682 |
+
axes[0].grid(alpha=0.3)
|
| 1683 |
+
|
| 1684 |
+
# 2. Person clusters
|
| 1685 |
+
unique_ids = np.unique(person_ids)
|
| 1686 |
+
colors = plt.cm.tab20(np.linspace(0, 1, min(20, len(unique_ids))))
|
| 1687 |
+
|
| 1688 |
+
# Show top 15 most frequent IDs
|
| 1689 |
+
id_counts = {pid: np.sum(person_ids == pid) for pid in unique_ids}
|
| 1690 |
+
top_ids = sorted(id_counts.items(), key=lambda x: x[1], reverse=True)[:15]
|
| 1691 |
+
|
| 1692 |
+
for idx, (pid, count) in enumerate(top_ids):
|
| 1693 |
+
mask = person_ids == pid
|
| 1694 |
+
color = colors[idx % len(colors)]
|
| 1695 |
+
|
| 1696 |
+
# Plot the cluster points
|
| 1697 |
+
axes[1].scatter(embeddings_2d[mask, 0], embeddings_2d[mask, 1],
|
| 1698 |
+
c=[color], label=f'ID {pid} (n={count})', alpha=0.7, s=25)
|
| 1699 |
+
|
| 1700 |
+
# Compute the centroid (mean position) of the points in this cluster
|
| 1701 |
+
centroid = np.mean(embeddings_2d[mask], axis=0)
|
| 1702 |
+
|
| 1703 |
+
# Add the person ID text at the centroid
|
| 1704 |
+
axes[1].text(centroid[0], centroid[1], f'ID {pid}', fontsize=10, color='black', alpha=0.8, ha='center')
|
| 1705 |
+
|
| 1706 |
+
# Plot others in gray
|
| 1707 |
+
other_mask = ~np.isin(person_ids, [pid for pid, _ in top_ids])
|
| 1708 |
+
if other_mask.any():
|
| 1709 |
+
axes[1].scatter(embeddings_2d[other_mask, 0], embeddings_2d[other_mask, 1],
|
| 1710 |
+
c='gray', label='Others', alpha=0.3, s=15)
|
| 1711 |
+
|
| 1712 |
+
axes[1].set_title(f'Person Clusters - Epoch {epoch}')
|
| 1713 |
+
axes[1].legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=8)
|
| 1714 |
+
axes[1].grid(alpha=0.3)
|
| 1715 |
+
|
| 1716 |
+
# 3. Distance-based coloring
|
| 1717 |
+
scatter = axes[2].scatter(embeddings_2d[:, 0], embeddings_2d[:, 1],
|
| 1718 |
+
c=distances, cmap='viridis', alpha=0.7, s=20)
|
| 1719 |
+
plt.colorbar(scatter, ax=axes[2], label='Distance')
|
| 1720 |
+
axes[2].set_title(f'Distance Visualization - Epoch {epoch}')
|
| 1721 |
+
axes[2].grid(alpha=0.3)
|
| 1722 |
+
|
| 1723 |
+
plt.tight_layout()
|
| 1724 |
+
plt.savefig(os.path.join(figures_dir, f"tsne_epoch_{epoch}.png"),
|
| 1725 |
+
dpi=150, bbox_inches='tight')
|
| 1726 |
+
plt.close()
|
| 1727 |
+
|
| 1728 |
+
|
| 1729 |
+
def train(self):
|
| 1730 |
+
"""Main training loop."""
|
| 1731 |
+
torch.backends.cudnn.benchmark = True
|
| 1732 |
+
self.logger.info(f"Starting training on device: {self.device}")
|
| 1733 |
+
|
| 1734 |
+
# Prepare components
|
| 1735 |
+
train_dataset, val_dataset = self._prepare_datasets()
|
| 1736 |
+
model, optimizer, scheduler = self._setup_model_and_optimizer()
|
| 1737 |
+
scaler = torch.amp.GradScaler(self.device.type, enabled=(self.device.type == "cuda"))
|
| 1738 |
+
|
| 1739 |
+
# MLflow setup
|
| 1740 |
+
with self.mlflow_manager.start_run(run_id=self.config.run_id):
|
| 1741 |
+
run_id = mlflow.active_run().info.run_id
|
| 1742 |
+
self.mlflow_manager.log_config(self.config)
|
| 1743 |
+
|
| 1744 |
+
# Setup checkpoints
|
| 1745 |
+
checkpoint_dir, figures_dir = self._setup_checkpoint_management(run_id)
|
| 1746 |
+
|
| 1747 |
+
# Load checkpoint if specified
|
| 1748 |
+
start_epoch = self._load_checkpoint(model, optimizer, scheduler, scaler)
|
| 1749 |
+
|
| 1750 |
+
# Data loaders
|
| 1751 |
+
val_loader = DataLoader(
|
| 1752 |
+
val_dataset, batch_size=self.config.batch_size, shuffle=False,
|
| 1753 |
+
num_workers=4, pin_memory=True, prefetch_factor=2
|
| 1754 |
+
)
|
| 1755 |
+
|
| 1756 |
+
# Training loop
|
| 1757 |
+
for epoch in range(start_epoch, self.config.max_epochs + 1):
|
| 1758 |
+
self.current_epoch = epoch
|
| 1759 |
+
|
| 1760 |
+
# Update curriculum
|
| 1761 |
+
train_dataset.set_epoch(epoch)
|
| 1762 |
+
train_loader = DataLoader(
|
| 1763 |
+
train_dataset, batch_size=self.config.batch_size, shuffle=True,
|
| 1764 |
+
num_workers=4, pin_memory=True, persistent_workers=True, prefetch_factor=2
|
| 1765 |
+
)
|
| 1766 |
+
|
| 1767 |
+
# Training phase
|
| 1768 |
+
train_metrics = self.train_epoch(model, train_loader, optimizer, scaler, epoch)
|
| 1769 |
+
|
| 1770 |
+
# Validation phase
|
| 1771 |
+
threshold, eer, val_results = self.validate_epoch(model, val_loader, epoch)
|
| 1772 |
+
|
| 1773 |
+
# Logging
|
| 1774 |
+
self._log_epoch_metrics(train_metrics, val_results, epoch)
|
| 1775 |
+
|
| 1776 |
+
# Visualizations
|
| 1777 |
+
self._create_visualizations(val_results, epoch, figures_dir)
|
| 1778 |
+
|
| 1779 |
+
# Model checkpoint management
|
| 1780 |
+
is_best = eer < self.best_eer
|
| 1781 |
+
if is_best:
|
| 1782 |
+
self.best_eer = eer
|
| 1783 |
+
self.patience_counter = 0
|
| 1784 |
+
else:
|
| 1785 |
+
self.patience_counter += 1
|
| 1786 |
+
|
| 1787 |
+
self._save_checkpoint(
|
| 1788 |
+
model, optimizer, scheduler, scaler, epoch,
|
| 1789 |
+
threshold, eer, checkpoint_dir, is_best
|
| 1790 |
+
)
|
| 1791 |
+
|
| 1792 |
+
# Early stopping
|
| 1793 |
+
if self.patience_counter >= self.config.patience:
|
| 1794 |
+
self.logger.info(f"Early stopping after {self.config.patience} epochs without improvement")
|
| 1795 |
+
break
|
| 1796 |
+
|
| 1797 |
+
# Learning rate scheduling
|
| 1798 |
+
if self.config.lr_scheduler == "cosine":
|
| 1799 |
+
scheduler.step()
|
| 1800 |
+
else:
|
| 1801 |
+
scheduler.step(eer)
|
| 1802 |
+
|
| 1803 |
+
# Memory cleanup
|
| 1804 |
+
gc.collect()
|
| 1805 |
+
torch.cuda.empty_cache()
|
| 1806 |
+
|
| 1807 |
+
# Final logging
|
| 1808 |
+
mlflow.log_metric("final_best_eer", self.best_eer)
|
| 1809 |
+
self.logger.info(f"Training completed. Best EER: {self.best_eer:.4f}")
|
| 1810 |
+
|
| 1811 |
+
# ----------------------------
|
| 1812 |
+
# Image Processing Utilities
|
| 1813 |
+
# ----------------------------
|
| 1814 |
+
def estimate_background_color_pil(image: Image.Image, border_width: int = 10,
|
| 1815 |
+
method: str = "median") -> np.ndarray:
|
| 1816 |
+
"""Estimate background color from image borders."""
|
| 1817 |
+
if image.mode != 'RGB':
|
| 1818 |
+
image = image.convert('RGB')
|
| 1819 |
+
|
| 1820 |
+
np_img = np.array(image)
|
| 1821 |
+
h, w, _ = np_img.shape
|
| 1822 |
+
|
| 1823 |
+
# Extract border pixels
|
| 1824 |
+
top = np_img[:border_width, :, :].reshape(-1, 3)
|
| 1825 |
+
bottom = np_img[-border_width:, :, :].reshape(-1, 3)
|
| 1826 |
+
left = np_img[:, :border_width, :].reshape(-1, 3)
|
| 1827 |
+
right = np_img[:, -border_width:, :].reshape(-1, 3)
|
| 1828 |
+
|
| 1829 |
+
all_border_pixels = np.concatenate([top, bottom, left, right], axis=0)
|
| 1830 |
+
|
| 1831 |
+
if method == "mean":
|
| 1832 |
+
return np.mean(all_border_pixels, axis=0).astype(np.uint8)
|
| 1833 |
+
else:
|
| 1834 |
+
return np.median(all_border_pixels, axis=0).astype(np.uint8)
|
| 1835 |
+
|
| 1836 |
+
def replace_background_with_white(image_name: str, folder_img: str,
|
| 1837 |
+
tolerance: int = 40, method: str = "median",
|
| 1838 |
+
remove_bg: bool = False) -> Image.Image:
|
| 1839 |
+
"""Replace background with white based on border color estimation."""
|
| 1840 |
+
image_path = os.path.join(folder_img, image_name)
|
| 1841 |
+
image = Image.open(image_path).convert("RGB")
|
| 1842 |
+
|
| 1843 |
+
if not remove_bg:
|
| 1844 |
+
return image
|
| 1845 |
+
|
| 1846 |
+
np_img = np.array(image)
|
| 1847 |
+
bg_color = estimate_background_color_pil(image, method=method)
|
| 1848 |
+
|
| 1849 |
+
# Create mask for background pixels
|
| 1850 |
+
diff = np.abs(np_img.astype(np.int32) - bg_color.astype(np.int32))
|
| 1851 |
+
mask = np.all(diff < tolerance, axis=2)
|
| 1852 |
+
|
| 1853 |
+
# Replace background with white
|
| 1854 |
+
result = np_img.copy()
|
| 1855 |
+
result[mask] = [255, 255, 255]
|
| 1856 |
+
|
| 1857 |
+
return Image.fromarray(result)
|
| 1858 |
+
|
| 1859 |
+
# ----------------------------
|
| 1860 |
+
# Main Execution
|
| 1861 |
+
# ----------------------------
|
| 1862 |
+
def main():
|
| 1863 |
+
"""Main execution function with aggressive curriculum."""
|
| 1864 |
+
# Test distance ranges first
|
| 1865 |
+
print("\n[INFO] Testing distance ranges for margin calibration...")
|
| 1866 |
+
dummy_emb1 = F.normalize(torch.randn(1000, 128), p=2, dim=1)
|
| 1867 |
+
dummy_emb2 = F.normalize(torch.randn(1000, 128), p=2, dim=1)
|
| 1868 |
+
dummy_distances = F.pairwise_distance(dummy_emb1, dummy_emb2).numpy()
|
| 1869 |
+
print(f"Random embeddings: mean={dummy_distances.mean():.3f}, std={dummy_distances.std():.3f}")
|
| 1870 |
+
print(f"Expected margin range: {dummy_distances.std() * 0.5:.3f} - {dummy_distances.std() * 1.5:.3f}")
|
| 1871 |
+
|
| 1872 |
+
# Aggressive curriculum configuration
|
| 1873 |
+
CONFIG.model_name = "resnet34"
|
| 1874 |
+
CONFIG.embedding_dim = 128
|
| 1875 |
+
CONFIG.max_epochs = 20 # Shorter with aggressive curriculum
|
| 1876 |
+
CONFIG.head_lr = 2e-3 # Higher for faster adaptation
|
| 1877 |
+
CONFIG.backbone_lr = 1e-4
|
| 1878 |
+
CONFIG.curriculum_strategy = "progressive"
|
| 1879 |
+
|
| 1880 |
+
# AGGRESSIVE SETTINGS
|
| 1881 |
+
CONFIG.initial_hard_ratio = 0.4 # Start much higher
|
| 1882 |
+
CONFIG.final_hard_ratio = 0.85 # Target very high
|
| 1883 |
+
CONFIG.curriculum_warmup_epochs = 1 # Very short warmup
|
| 1884 |
+
CONFIG.batch_size = 256 # Smaller batches for more frequent updates
|
| 1885 |
+
CONFIG.grad_accum_steps = 8 # Smaller accumulation
|
| 1886 |
+
|
| 1887 |
+
CONFIG.tracking_uri = "http://127.0.0.1:5555"
|
| 1888 |
+
#CONFIG.run_id = "aa58e3a1f3314351bc1dd2b82ab156ad"
|
| 1889 |
+
#CONFIG.last_epoch_weights = "../../model/models_checkpoints/aa58e3a1f3314351bc1dd2b82ab156ad/best_model.pth"
|
| 1890 |
+
|
| 1891 |
+
trainer = SignatureTrainer(CONFIG)
|
| 1892 |
+
trainer.train()
|
| 1893 |
+
if __name__ == "__main__":
|
| 1894 |
+
main()
|
Training model - validation - organigram.zip
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c635ee72dc87e1ac02c03a9aa720a4723c1d5130222f1fedb59758d277797764
|
| 3 |
+
size 251911552
|