From 475dcf41017a91369269b67e339e2d791b665768 Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:29:42 +0800 Subject: [PATCH 01/11] Fix fraction simplification in tutorial 3 --- docs/tutorials/tut03.ipynb | 129 +++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 69 deletions(-) diff --git a/docs/tutorials/tut03.ipynb b/docs/tutorials/tut03.ipynb index 0d3fdc7..ff2ef9b 100644 --- a/docs/tutorials/tut03.ipynb +++ b/docs/tutorials/tut03.ipynb @@ -10,7 +10,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 55, "id": "7e5ccf28", "metadata": {}, "outputs": [], @@ -35,7 +35,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 56, "id": "8c13e844", "metadata": {}, "outputs": [ @@ -201,7 +201,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 57, "id": "481222df", "metadata": {}, "outputs": [ @@ -221,7 +221,7 @@ "⎝⎣0 0 0 1⎦ ⎣0 0 0 1⎦ ⎣0 1 0 0⎦ ⎣0 0 0 1⎦⎠" ] }, - "execution_count": 3, + "execution_count": 57, "metadata": {}, "output_type": "execute_result" } @@ -253,7 +253,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 58, "id": "0ad9f535", "metadata": {}, "outputs": [ @@ -271,7 +271,7 @@ "])" ] }, - "execution_count": 4, + "execution_count": 58, "metadata": {}, "output_type": "execute_result" } @@ -286,7 +286,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 59, "id": "c9ba32b1", "metadata": {}, "outputs": [ @@ -422,7 +422,7 @@ "]))" ] }, - "execution_count": 5, + "execution_count": 59, "metadata": {}, "output_type": "execute_result" } @@ -435,7 +435,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 60, "id": "290a2467", "metadata": {}, "outputs": [ @@ -453,7 +453,7 @@ "⎣⎣3⎦⎦" ] }, - "execution_count": 6, + "execution_count": 60, "metadata": {}, "output_type": "execute_result" } @@ -465,7 +465,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 61, "id": "b223ea2e", "metadata": {}, "outputs": [ @@ -483,7 +483,7 @@ "⎣⎣3 ⎦⎦" ] }, - "execution_count": 8, + "execution_count": 61, "metadata": {}, "output_type": "execute_result" } @@ -505,7 +505,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 62, "id": "66178be7", "metadata": {}, "outputs": [ @@ -523,7 +523,7 @@ "])" ] }, - "execution_count": 9, + "execution_count": 62, "metadata": {}, "output_type": "execute_result" } @@ -538,7 +538,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 63, "id": "1874537d", "metadata": {}, "outputs": [ @@ -674,7 +674,7 @@ "]))" ] }, - "execution_count": 10, + "execution_count": 63, "metadata": {}, "output_type": "execute_result" } @@ -686,7 +686,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 64, "id": "0e665b60", "metadata": {}, "outputs": [ @@ -704,7 +704,7 @@ "⎣⎣5⎦⎦" ] }, - "execution_count": 11, + "execution_count": 64, "metadata": {}, "output_type": "execute_result" } @@ -716,7 +716,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 65, "id": "f892bc67", "metadata": {}, "outputs": [ @@ -740,7 +740,7 @@ "⎣⎣ 1 ⎦⎦" ] }, - "execution_count": 12, + "execution_count": 65, "metadata": {}, "output_type": "execute_result" } @@ -772,7 +772,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 66, "id": "577145b8", "metadata": {}, "outputs": [ @@ -792,7 +792,7 @@ "])" ] }, - "execution_count": 13, + "execution_count": 66, "metadata": {}, "output_type": "execute_result" } @@ -804,7 +804,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 67, "id": "6529a57f", "metadata": {}, "outputs": [ @@ -1108,7 +1108,7 @@ "]))" ] }, - "execution_count": 14, + "execution_count": 67, "metadata": {}, "output_type": "execute_result" } @@ -1130,7 +1130,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 68, "id": "7597a9a2", "metadata": {}, "outputs": [ @@ -1148,7 +1148,7 @@ "])" ] }, - "execution_count": 15, + "execution_count": 68, "metadata": {}, "output_type": "execute_result" } @@ -1160,7 +1160,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 69, "id": "9e0c6fc2", "metadata": {}, "outputs": [ @@ -1175,7 +1175,7 @@ "-(x - 2)⋅(x - 1) " ] }, - "execution_count": 16, + "execution_count": 69, "metadata": {}, "output_type": "execute_result" } @@ -1196,7 +1196,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 70, "id": "250486d7", "metadata": {}, "outputs": [ @@ -1214,24 +1214,22 @@ "])" ] }, - "execution_count": 17, + "execution_count": 70, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "a, b, c, p, q, r, u, v, w, x = sym.symbols(\"a b c p q r u v w x\")\n", - "\n", - "lhs = Matrix([[a + p*x, b + q*x, c + r*x],\n", - " [p + u*x, q + v*x, r + w*x],\n", - " [u + a*x, v + b*x, w + c*x]])\n", + "lhs = Matrix.from_str(\"a + p*x, b + q*x, c + r*x;\" \\\n", + " \"p + u*x, q + v*x, r + w*x;\" \\\n", + " \"u + a*x, v + b*x, w + c*x\", col_sep=\",\")\n", "\n", "lhs" ] }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 71, "id": "602da8f2", "metadata": {}, "outputs": [ @@ -1249,7 +1247,7 @@ "↪ p⋅v⋅x + c⋅p⋅v - c⋅q⋅u⋅x - c⋅q⋅u" ] }, - "execution_count": 18, + "execution_count": 71, "metadata": {}, "output_type": "execute_result" } @@ -1260,7 +1258,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 72, "id": "c03ee4b3", "metadata": {}, "outputs": [ @@ -1278,7 +1276,7 @@ "])" ] }, - "execution_count": 19, + "execution_count": 72, "metadata": {}, "output_type": "execute_result" } @@ -1290,7 +1288,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 73, "id": "467139d1", "metadata": {}, "outputs": [ @@ -1304,7 +1302,7 @@ "a⋅q⋅w - a⋅r⋅v - b⋅p⋅w + b⋅r⋅u + c⋅p⋅v - c⋅q⋅u" ] }, - "execution_count": 20, + "execution_count": 73, "metadata": {}, "output_type": "execute_result" } @@ -1315,29 +1313,22 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 74, "id": "e5ef0569", "metadata": {}, "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAxwAAAAyCAYAAADWSd3TAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAW70lEQVR4Ae2dX5LcthHGRxsdQFaeUnmKnRPYyrursr6BFZ/A8Q2i0pP15pJvIPsEln0DO1V+958b2MkFotobbL4fFqBALGeGnOEfkPN1FZcE2AC6v242GyQ4++D29na3BXrx4sW70uNa2xttHP9N2xeq/1V7kxFoIWB/acHhwh4E7Cd7gHF1CwH7SQsOF4SAfcJuUCJw6T7xsARkxeWXkv13GfQrdND+X9r9W9s7lE1GoEDA/lIA4mInAvaTTlhcWSBgPykAcXFnn7ATlAhctE9sacLxRWHZP6rM2w6TEehCwP7ShYrrSgTsJyUiLnchYD/pQuWy6+wTl23/Lu0v2ic2M+HQG41y6dTHsjazSZMRuIeA/eUeJK7oQMB+0gGKq+4hYD+5B8nFV9gnLt4F7gFw6T6xmQlHsqwMylKqT7S90nFYXpXOeW8ESgTsLyUi05SF8yP1/K22J9reqPzeNCNN02sNfrJ2DIdaZo36Lu0na8RsqF+sjX9pn1gbXpcg76X6xIOtfDSeO2kWdL/X8Zf5OR8bgRIB+0uJyHRlYf2Lev9Z+8+mG2WanmvxkzVjeIpl1qZvDX6yNsxO8Ys1tanBJ9aE1yXIeok+cbVFw8qQN9LrmbaXOn7/FB3V7lrbP09p6zbrQmAMf1mXxotKy/X4/aISnDh4RX6yGgxHiqOr0RfXOtdPLgGzkXQ88Uqev1klPjG/4h5xLwLn+sTejis+sYklVTIcyzX+o+3vOk7fcjDpgFjCkepCRc8/9Mlm2hgCE/nLxlAaXx3hfh17/WH83sfvsUY/WRuGsspZcXQN+k7gJ5vH7Fy/GP9qH7fH2nxiXO3c2ykITOATp4ixaJtNvOGQIW+EIr9I9XuGJskN9a+zOh8agZ39ZTEn+Egj89PVXJfVU6V+sioMRzBy9fpW6CfVYzaCX1TdRYU+UTVelyCcfWK3a73hECD8w7zwO8Ha/0/bd9o+Vv297yAy3p/Ew0/Q8nNfLEH6Qed+1cZTGsqc+03l5gNuHfOK/DPtwzpu7dMvSn2g4yYZ0XHqQ+zhH/l9qj0y8lE49JN4kBF6qu25ysgN8Y//Wv2F2pn+SI6qsJxJ7V0fvcWD/bHh+zr+KLZhCRy2b3xF9cn+XT70m3ifiSfZf5fx69T2/EX6TYIbYM1EPAQgNvDDDhDX6DcqBxtG/eChPl3rlLE/19On4rnRdsgvjsaWrL26bI1VfVyRvFVgCHAzUR99h8QSfqzgF/lAcz9CD5X5QYPgX5Qh1SU/o5j7ZO1+shhmADUXyT7kKul+z72Db8OIL8QAMMhtRnlIHLl3f1H7nfpeq08gPvLPjlkYeKY/+/RLw+t8ysuO5a2XECcCLHNidpUZgguSDzpJ4tiYZLzSxncQXGQNqQwv67ADH/w6/lobvGn5Esk/fWDY8udpn6sOwyciWFBm+VNO9Pdl1g9jXKvMeMjU9Ks6Ak2ShzZPteVvPPJ+Jz3WuDViOanOdD5A70/Eiw1pg481k08dNzbV8SEfeqzz6cavw0Bb95epcEv4TbaXnbleSQS4zr9SmdjApOJbHadYgH7p4Qb/tPNdytrwFa5lkkLokF/0iS1r9ZOaMLyzxIR/Zfe++g6JJdy38hiz0zg8GOPB2k2hzur8pALMCgjHL0pH4gITAh44pvzgA5WTXceII133F5RZnU8g9MKYIcKk1EM/MBiSt246TmCMJTC7igMT2LmZP5MQeZLOcWsJhM4nXi68nJeuwmRD9SQWTDQgXu+y3CmnZPhQJ37WdNO26U913ARSAIGPmwFvQtKTKQJCfl7F5UlyJ3yqwXIOVPrqLT6SS24WEDa8Vl1IGHQMdsG+qit9qPEN8UAkq02d+DftL1PhFpCc5w/XPMR3VlzLu7gP17WOc3vjI8Sd5u2VyvgMvpLzDY4tar9KP5HuUBUY3okyy99j+p4aS8r7EX4U7l1JqxX7yWKYJexm2JMMflfEh3+ojl+lzOPDkDjS3Eui/K37C3Ur9gnEXwQzBp6J9urH+LJdysvOyVu3FCeAZXbMHjKqiMT9kYySkvlQqT8Er/ymT/0+Xi70xEuykAI4gaD574qqhw/jlx+O8jo0v+jLMk8weIuRkpWnKp9N6o8n7ClI5/09pqDzXT/fiRz7xt+Hz5JY5nqhE/jzBJl9X+KNUbJpV5u+ejPm69gBvtDgq/6bY9Xv9aHYNvcVqubyl7GxWxq3COfb3UT+EYK1+r55O1I4Ak+WOuT2xi+amBG43vrqm8wPT4kta/UTYKgFw2iStzvZZOw42kffsWIJsbn0t8n9ZIOYvXWIeDS2jrE/JhIte6n+HYbUnjwm3adOiiNR9PL+QvUqfUJ4cG0uhVmE826HfXQ0au5xTL8owL77LD5yNG+NfSwVJzaD2cMIJDfu1gQgOgZOyiwopy5e+Bpetb2hgfa8kQCsfCKD0ViHnYKCioHSU+9Q6DjfSi5im7N3GidPcpv+ouxhSUdT2e+gCx8waPDJuuniHR3LbLxwKN2wDxO4MalLl3t6J7tqjx9AafJxV4p/o4w77bt8CK5rnUvLb+Ar/Wkqfxkbu0Vxa4Hexn5s/8DeKbCHkWSz5AMsjQBX7JjqWvFIp0i2iRv/1R6+Lr+g7cHYonZr9RPUrgJDBClJuI4dRxnimL7Blho7+cyxWAIfMam5H6kt8Za6lr/N4Sdbw0wY3qMJdAzxUv2GeFEOmOq1Tz7Rsqv4+8QRum3dX6hQn5PHDo0xxXW0GGbglpP0w25j31sO6hfH77rP7su1aosTm8HsSg5AsGXrmljsdL65YA/whos7541G7noiR13TJ3xqxyyzvJg5FUjnkwM0CYvqeJKB3NVQlKdqLKcAa4je2fi8IeKp9k1W13WIv3Tx8VS8k9Tn5vwlU3Qy3LIxpjgkuKdllqn/9JYwjwf77I1N84Ty7NiyFj9JYGlfHYaZbFMc9tW37zXRxcfEdSdfWP39JxqgCsymcAbZiHtr1/21a7ghcWTQ/YXBJMva7zGzY9Y14Ll1fXziAA82xJb5/YeqTceJA3ige6ADPGdh9jANoH35CjHc0DmvwQnKBOQ3lEUlLwbqCtgEv5KXumbioGOIj7zSOn7GI6jwTQnr7XAGZv08ucz74sPRpo3O10S5nMi1GJYzg3JU78yGOG7pB13i4i8t31If+COviQOpvHl/mQK3hN+M+8Y/os146sQPBtxkMuAXrRuAzvPdBTz59T44tsQx1xxXgGlRDBFgZuqr70mxRLoQm4O/RT9Lk9o1+8nsmBXX8CQuwhja6DuPF81YOnetLcWOIXHk4P2FAdTvKu8xknsxzBrDTHjQRz8N/3MUobkuYvlQ3tryCfFvJk4siRlvOLh4uUi5gQdSHQkdbx2Sofjp0vQUoOQlGbh3cYeO7hJFLtRA6gO+x9rysagr365Qx8Z6bdq/0dZQ7Kd8WtqcX+pActWI5eRwDNEbYcSP/dlKu3O6JC783F9CW/WRB4/N+wugTIBbifWUZRLCJ9kAJHSvpVO+vIVrnbhT2puJRvOxuY4h/GJobFmlnwRt7/7UgGEmzuSHffQN8UCS9IkleczgesLX8MmUXLynOmL4mv1kKcwE2yxEvCBRbJHsxhr9kCfoeGgcKeMNS6lbvqK+1+wTS2HWstGEhYP6xWt6SN7asr3abzFOLILZg9vb250A5QL9WhtJPEtVvtFG4OUipi78bw3tS16qMA7JA5OS9HSB+kCqS0+i6Y/fzEZRxiI4UNf83wUdB4ryMHb4rkNlfvqOSRCzTH66l4kIgXUyiuMN/oZD7arCcjKAio4H6k3wfqU2/Nb1URIfPsRENSQVKuNDDcWxF/WXRpiBB1H2vtfeqLgNFPUs9kxP4gXXSNf/QuAaJ5awxhddIeIRvtK6CXBCdYNiS5RhlX4S9U2xZTEMkWMICXNsOjiOMka0F9fGIX17XxNFf9yL6JfJRus+F/kW8xONvzrMhOEgOlNHbAOl7z65Lvip7RsqY99D48je+0vskzFW6RNR/tkxY9y5SDY/pl+KneSzENd+Z96qvhIvPNXGCZQ4h5bALEw4zhSaNxwkBA/O6ae2ttLn5KB/qi4ac5NYnoqH210WAvJ/JhBPtGfCYToBgdowlDyzx9ETYKuqySVgNqWOtV0DYzjXlHgh3xYxO4S79HWudQigjnNjYHbV0e/Qqub7hKENK+fnaQnbnLRVLOfE0GOtFwGeVt97S7pedRaRvDYMl4ijiwA/4qCXgNmUOtZ2DYzhGlPihXxbxOwQ7s61DqHTfe5szB529zuoFkdtLXEZ1LpSZs3mlkh8NollpSa2WBUhoOvtkcRhLXV65V2RdOsQpUYMF4qj6zDYHikvAbOpdKzxGthj5kHVU+GFEFvF7AjAzrWOANRx+mzM/iBn6+j3eJXavfzxxx9ZN/cXbX/S8Z8//PDDJZL048JWzmEsKzeQxZsUAfk/y25Yq/8nbX9VLNkplvCtlqknAsawJ1Bm2ywCvgaGm/bSMJO+zlsHusmYmJ39DcdA2c1uBIyAETACRsAIGAEjYASMwAUhMMY3HBcEl1U1AkbACBgBI2AEjIARMAJGYAgCnnAMQcu8RsAIGAEjYASMgBEwAkbACAxCwBOOQXCZ2QgYASNgBIyAETACRsAIGIEhCDz4/PPPb4c0MK8RMAJGwAgYASNgBIyAETACRqAvAv5ovC9S5jMCRsAIGAEjYASMgBEwAkZgMAJeUjUYMjcwAkbACBgBI2AEjIARMAJGoC8CnnD0Rcp8RsAIGAEjYASMgBEwAkbACAxGwBOOwZC5gREwAkbACBgBI2AEjIARMAJ9EfCEoy9S5jMCRsAIGAEjYASMgBEwAkZgMAIPB7dwAyNgBIyAEZgMgRcvXjxS599qe6LtjcrvTTZYBR1fmr4VQG4RjIARMAKzI+A3HLND7gGNgBEwAvsRUAJ+o+0jcfyu7Yf9nNs4c2n6bsNq1sIIGAEjMAwBTziG4WVuI2AEjMBcCLyvgb6fa7Bzx9HE4VrbP8/op2p9R9DvDGjc1AgYASOwbgQ84Vi3/Sy9ETACG0SA5DaqtaY3HCwFYxtMK9H3ZP0GA+IGRsAIGIGNIeAJx8YManWMgBHYBAJhSZUS8ZtNaHNciUvT9zgi5jACRsAIbAgBfzS+IWNalctDQAnpu9L6pTbW+/9P23faPlb9l9q3KOP9SSf+qO0LbSyB+UHnftXGE1zKnPtN5a+0D6Rjlrt8pv1nVGj/sXaM+4GOZ0+KNeZRvcWDzJ9oe1/HH8U2z1RG3kY/1R/S+zfxPhMPuM5JvOHAJv+Kg/5N+2+SHNqjGzzUf6oNPChjO44/FQ/fghzSrSab9tF3iC350P4X6d/4sMo7lfkYP2BDeQmSDFw3XKsQvviz6rD1GDZdyl+DMvt0Cyf1R+fTdXssBlVrv6SL90bACAxD4GoYu7mNgBGoBQHdvEnSftFGQszGJOOVtpc6JtFsKPLyPUDgg1/HX2uD99fI+FzH9EEyQFKU03MVSBYSkShR5peUZqWoSx+9P4l67rQHl2bCpONcv0N6PxYvie5sJFmxHckn+H6lMjZhUvGtjpMN0C1NKv8NL2Vt2JXJJ4k1dEi3Kmwqmfvqi2478fexJb6e25h2TKaZjOO7s5PGxUZMCH7SHlthvw+0JTnHsOns/gqQ0uWYbvAQr/rGoOrsh54mI2AETkfg6vSmbmkEjMBSCOjmTZJGUskEggQzEce/q65JqjJeJhc5L23CZEP1JLhMNCCWt7wJR2//pGQh1IifbwtoW/b3tsUER5kuB/UWH4k5yR1EEnatupCw6hjswpNv1ZV6l/qQ6Jd1qpqUwBr6u+QLdox7jkmYc5nRE3vnb2DQG31zvmptiqzaoH36nmrL0ofBIPh7GG3+PyTR3xW2+ofqvi9sNcSmpW8u4a8guVc3Tko/rjni1TkxaGn7oYrJCBiBExF4eGI7NzMCRmBZBHgq+kg38taSEdWRvOXJJ1Lu4yUhTbwkrSkZIwliuVUg1cNHwlB+wMxSkDLhuWukvzpHG56+s+9LT9UuydHVZp8upd6M+Tp2gPxhKRhl9d8cq7hXb3hFe/W7Oz2JniGxkpw3aYy4RyeWTOUyo1tjq4yPQ/6HR8JyFJvSqfrkDUOaJFCV6DEHOp/jm86xZOhpKhT7Y/qOZUtkLrEqRJlEPzABMyYSrfFV/w4CaM+1nGx1kk3pR3TQXxlHPKNek+rzoG5BqjNjUOyjl/0ir3dGwAhUhsDDyuSxOEbACPRDgASyNQGIyQRJDU8bc+riha/hVduQ3GrPtxkkJflEhhs93wOkhEjFQOkNQiq39rFPloyMSV26IG+jC4MlWbVHdihNPu5K8W+UEf4uveG61rm0dKnVNhViH2PqicxpIhiG0RhJD5bjJFulupYfqAEJPPb6L42179KNtoNtGvvrmlCkccLSLvgG0DF9g99Jj6TvMVvC1/JhtcU/qCuxuiemeMfWjzGC36rvYLty0FSvfdKxlLOPTen2oL/Gccb0VcY8qBsMoq7rFpuwhXhVYHCy/RjMZASMQH0IXNUnkiUyAkbgEAK6MXMzZuuaWOx0vklWDvCGxCbnjWN2PW2mrukTPrXjKWw5AeHUZHRAF5IZZGrJGAXhqTpvBDoTvcjDDh27+HijMDeRhKXlbWns9HYg13GfzNg2T8qrtWlUrq++fW3ZxcekCx+Z1WfjmPuu16h+azfEpov7q/A8qtsBnn0xqCr7tazjghEwAicj4AnHydC5oRFYHIFy+URILJFKN3nW+pPIJSp5ual3JV+0KXm76vjANU9+0zhz7Ev5DulNUtN6W7BHQHRs4QGGqmO5yBLU6Cg5SOqYVPHRez5xQreWDXSeD6PhSd+r6DA8RW76o0JUrU2P6HuSLaUvPhKwAqM4BjhMThor2SztW2PqfEi8Y+UQmy7urwN1K33wUAxq6SZsFrNfy1guGAEjcDICVye3dEMjYAQWQSDe5EmeSBoDqY7kmLcOP9/V7PgZ2PQEtOQlKb2X2MR23OgfxeOd+oCPtfn5WNSVb1dSk8n2koWErdSlU2+EED8ys/WRFb1zHUNb9VEmSWKbnEiqn2Sj8LHta8nSLHPTMTbC3qXMTDSaj691DFVr0zvxwoTwmL7BHuLvY8uWzYQVONF/SmLfU11n8h/lmWKH7UiwWyQ5+CbpDZU6HmrT0vYsZ2vp3hpsusJB3SRT13V7KAa1dFD7Guw3HXru2QhcCAIPbm9vL0RVq2kEtoNATE6+lkYsvWHZzzfauLGTwFAX/reG9ruClypu6CSxTErCU18qE6kuPdWnP/5fAAkFY5EYUdf8Dwsdz0qFLsf0ZmL0Sm34Tf+jFPVmchWSWpWbBP9o4xEZMh2x0yNtXf9PgokWNmQ9PnpC4IG+rYSNE6qb3KYaA5kGf8OhduiIfx3St7cti/7wX/plsnHv2lBdbzpVvzSA2jM+lL59Qm9+9phrahf7H2rTxf01yn5Mt2RjYhOETTpjkHBIvPCMZj8GNRkBI7AcAp5wLIe9RzYCiyCgGzpPF0lMHywigAc9GwHZjgnEE+2ZcFRBkuWkCUcVwvcQYmr9arRpD1hOYpGujkEnIedGRmC9CFytV3RLbgSMwIkINN88nNjezZZHgCf+995OLSwWT+rD0/qF5Zhq+Kn1q9GmU2HpGDQVsu7XCFSKwMNK5bJYRsAITIcAic0iy4WmU+lyetbTYZacsH4/LU+pQnnJVdsEaFRcptSvVpuOCmC7M8egNh4uGYHNI+AlVZs3sRU0AncIKKlhnTU3ej7CZH00//U4/zUjVZlqRkD2YtnSc23YkO8SWBrnyaOAWCtdkk2lq2PQWh3VchuBMxH4P8/2ptYNMUC7AAAAAElFTkSuQmCC", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAADgAAAAVCAYAAAATtC32AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACF0lEQVRYCdWX7U3DQAyGU9QBWtiAEShMQEfgYwJgAxD/+q8qGwAbUDYAJuBjBJiAqhuU5zldo1MVEEmqKrHk2ufkbL93ju/aWSwWWVkajUa7zBnCM1j9AB5j/0A2iroVs5kw7xNAd85HXiJe4L7jJlFVgOMVEDuM3c3GUSWABaV4BDJ3tXFUCeASRSzNU8a36KFcl8+aIjtVmkyaPMB6jKfwE/pN+mzTOvH3Yi4D9LnxawPUSXT8jqrjjXZS4rnA97A9YB8WZB97AFi6RKPDL5wcJmCCsxigNED8DJm7iyxd5swx9jGcodvNBZjTVq79U4kOXa3PZIoJGughsZVRe7wsr51K72DMwBW7Bux3HHvQ53UfbY0QOUCSdQXPY1YmfAZ7S7FLSq+886iCtAxLl6JzN01piU5I/EYmiVfYD3fI+Aop+Eaec+T1J4UdBIQ7lwKYM/bwdhelbTh9Hoxt+FmW6Bsg06YxIPkPbALNkKFL1QWEn1t8DAv8uIDGuSh4Zh6V4y93cPV7OiHQ6n2zIHY50y8ABGa1eEys/aKQfoMhW4K4wn5zoaFoxNaT1dtG3Zi4Vy2bzDPSMpmjpyXrkWCzaR25g+6YPItgPcRzwuYzu2obyL9tUvimVbqwu+YVSSAZgC5gL842BO+XAs/L1XeaRuRnBUoBA3KKzQp8WstlW891iGQ212TqJFpjrsdROJJq+Cic+gNfwrG08vsxlwAAAABJRU5ErkJggg==", "text/latex": [ - "$\\displaystyle \\frac{a q w x^{3} + a q w - a r v x^{3} - a r v - b p w x^{3} - b p w + b r u x^{3} + b r u + c p v x^{3} + c p v - c q u x^{3} - c q u}{a q w - a r v - b p w + b r u + c p v - c q u}$" + "$\\displaystyle x^{3} + 1$" ], "text/plain": [ - " 3 3 3 3 ↪\n", - "a⋅q⋅w⋅x + a⋅q⋅w - a⋅r⋅v⋅x - a⋅r⋅v - b⋅p⋅w⋅x - b⋅p⋅w + b⋅r⋅u⋅x + b⋅r⋅u + c⋅ ↪\n", - "────────────────────────────────────────────────────────────────────────────── ↪\n", - " a⋅q⋅w - a⋅r⋅v - b⋅p⋅w + b⋅r⋅u + c⋅p⋅v - c⋅q⋅u ↪\n", - "\n", - "↪ 3 3 \n", - "↪ p⋅v⋅x + c⋅p⋅v - c⋅q⋅u⋅x - c⋅q⋅u\n", - "↪ ─────────────────────────────────\n", - "↪ " + " 3 \n", + "x + 1" ] }, - "execution_count": 21, + "execution_count": 74, "metadata": {}, "output_type": "execute_result" } @@ -1348,7 +1339,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 75, "id": "a84cf2c1", "metadata": {}, "outputs": [ @@ -1551,14 +1542,14 @@ "])" ] }, - "execution_count": 22, + "execution_count": 75, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Alternative way: less brute force\n", - "\n", + "x = sym.symbols(\"x\", real=True)\n", "(lhs.copy()\n", " .reduce_row(1, x, 2)\n", " .reduce_row(0, x, 1)\n", @@ -1570,7 +1561,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 76, "id": "5e747f6f", "metadata": {}, "outputs": [], @@ -1595,7 +1586,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 77, "id": "5b46b496", "metadata": {}, "outputs": [ @@ -1721,7 +1712,7 @@ "])" ] }, - "execution_count": 24, + "execution_count": 77, "metadata": {}, "output_type": "execute_result" } @@ -1745,7 +1736,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 78, "id": "d7956aef", "metadata": {}, "outputs": [ @@ -1763,7 +1754,7 @@ "])" ] }, - "execution_count": 25, + "execution_count": 78, "metadata": {}, "output_type": "execute_result" } @@ -1776,7 +1767,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 79, "id": "ecfe8de9", "metadata": {}, "outputs": [ @@ -1793,7 +1784,7 @@ "[-6, -3, 2]])" ] }, - "execution_count": 26, + "execution_count": 79, "metadata": {}, "output_type": "execute_result" } @@ -1808,7 +1799,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 80, "id": "8b704089", "metadata": {}, "outputs": [ @@ -1816,7 +1807,7 @@ "name": "stderr", "output_type": "stream", "text": [ - "C:\\Users\\yeesh\\AppData\\Local\\Temp\\ipykernel_32724\\2803142297.py:1: DeprecationWarning: The classical adjoint of the matrix is computed rather than the conjugate transpose.\n", + "C:\\Users\\yeesh\\AppData\\Local\\Temp\\ipykernel_8532\\2803142297.py:1: DeprecationWarning: The classical adjoint of the matrix is computed rather than the conjugate transpose.\n", " Please use self.adj() instead to remove ambiguity.\n", " A.adjoint()\n" ] @@ -1834,7 +1825,7 @@ "[-6, -3, 2]])" ] }, - "execution_count": 27, + "execution_count": 80, "metadata": {}, "output_type": "execute_result" } @@ -1845,7 +1836,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 81, "id": "5d8b30e3", "metadata": {}, "outputs": [ @@ -1863,7 +1854,7 @@ "])" ] }, - "execution_count": 28, + "execution_count": 81, "metadata": {}, "output_type": "execute_result" } From 6e8166bcc8f1f59582cc477951d097088e7f89bc Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sat, 9 May 2026 10:23:39 +0800 Subject: [PATCH 02/11] Fix IPython detection --- src/ma1522/utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ma1522/utils.py b/src/ma1522/utils.py index 2c6269a..f397ebb 100644 --- a/src/ma1522/utils.py +++ b/src/ma1522/utils.py @@ -255,12 +255,15 @@ def _is_IPython() -> bool: try: from IPython.core.getipython import get_ipython - shell = get_ipython().__class__.__name__ + ip = get_ipython() + if ip is None: + return False + shell = ip.__class__.__name__ if shell in ["ZMQInteractiveShell", "TerminalInteractiveShell", "Interpreter"]: return True # Jupyter notebook, qtconsole or terminal running IPython else: return False # Other type - except NameError: + except (NameError, AttributeError): return False # Probably standard Python interpreter except ImportError: return False # IPython module does not exist From 881d59d1fb4dd410b4da1128342663778e24025b Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sat, 9 May 2026 13:39:12 +0800 Subject: [PATCH 03/11] Fix orthogonality, transition matrix and gram-schmidt process bugs --- src/ma1522/symbolic.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/ma1522/symbolic.py b/src/ma1522/symbolic.py index 4692ba7..bd26e83 100644 --- a/src/ma1522/symbolic.py +++ b/src/ma1522/symbolic.py @@ -601,6 +601,16 @@ def ones(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: def diag(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: return Matrix(sym.diag(*args, **kwargs), aug_pos=aug_pos) + # Override + @property + def H(self): + """Returns the Hermitian transpose (conjugate transpose) of the matrix. + + This overrides the default behavior where .H calls .adjoint(), which in this + class is overridden to return the adjugate matrix. + """ + return self.T.conjugate() + # Override @property def T(self) -> Matrix: @@ -2358,9 +2368,9 @@ def inverse( if option is not None: X = Matrix.create_unk_matrix(r=self.cols, c=self.rows, symbol="x") if option == "left": - eqn = X @ self - sym.eye(self.cols) + eqn = X @ self - Matrix.eye(self.cols) else: - eqn = self @ X - sym.eye(self.rows) + eqn = self @ X - Matrix.eye(self.rows) sol = sym.solve(eqn, X.free_symbols) if isinstance(sol, list) and len(sol) > 0: @@ -3164,7 +3174,7 @@ def transition_matrix(self, to: Matrix, verbosity: int = 2) -> Matrix: display(M) print("\nAfter RREF:") display(res) - P = res[: self.cols, self.cols :] + P = res[: to.cols, to.cols :] return P # type: ignore ############################################### @@ -3334,7 +3344,7 @@ def is_mat_orthogonal(self, verbosity: int = 1) -> bool: if verbosity >= 1: print("self^T @ self") display(res) - return res.is_diagonal and all(entry == 1 for entry in res.diagonal()) + return res.is_diagonal() and all(entry == 1 for entry in res.diagonal()) def orthogonal_decomposition(self, to: Matrix, verbosity: int = 0) -> VecDecomp: """Decomposes the current vector (or matrix) into its orthogonal projection onto a subspace and its orthogonal complement. @@ -3472,11 +3482,12 @@ def gram_schmidt( orthogonal_set = [self.select_cols(0)] for i in range(1, self.cols): u = self.select_cols(i) + u_orig = u.copy() latex_eq = f"v_{i + 1} = {sym.latex(u)}" for _, v in enumerate(orthogonal_set, start=1): if v.norm() != 0: - latex_eq += f"- \\left(\\frac{{{sym.latex(v.dot(u))}}}{{{sym.latex(v.dot(v))}}}\\right) {sym.latex(v)}" - u -= (v.dot(u) / v.dot(v)) * v + latex_eq += f"- \\left(\\frac{{{sym.latex(u_orig.dot(v))}}}{{{sym.latex(v.dot(v))}}}\\right) {sym.latex(v)}" + u -= (u_orig.dot(v) / v.dot(v)) * v if verbosity >= 1: disp_u = u.copy() From ec8e29dcc3d155db067d1ed1c2c8b361d528c8e4 Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sat, 9 May 2026 20:48:57 +0800 Subject: [PATCH 04/11] Update docstring for display --- src/ma1522/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ma1522/utils.py b/src/ma1522/utils.py index f397ebb..588d3a1 100644 --- a/src/ma1522/utils.py +++ b/src/ma1522/utils.py @@ -277,11 +277,11 @@ def display(*args, opt: Literal["math", "dict"] | None = None, **kwargs) -> None Args: *args: The objects to display. - opt: + opt (Literal["math", "dict"] | None): - If "math", displays the object as a math expression. - If "dict", generates a LaTeX representation of the dictionary for display. - - If none, assumes the object can be passed into IPython's [`display`][IPython.display.display] function directly. + - If None, assumes the object can be passed into IPython's [`display`][IPython.display.display] function directly. **kwargs: Additional keyword arguments to pass to the display function. See Also: From c72b0af446fde5779a5c16c650c3f40d6c7a7b34 Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sat, 9 May 2026 21:36:56 +0800 Subject: [PATCH 05/11] Remove unused package-lock.json --- package-lock.json | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 package-lock.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index fe2965a..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "linear-algebra", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From bfcbca4307a534fe8fe8ae60842201369ba0a143 Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sun, 10 May 2026 00:16:08 +0800 Subject: [PATCH 06/11] Add more comprehensive tests to prevent regression --- ...lators_2.py => test_basic_manipulators.py} | 35 ++++++++ tests/test_decompositions.py | 89 +++++++++++++++++++ tests/test_factory_methods.py | 34 +++++++ tests/test_utils.py | 35 +++++--- 4 files changed, 181 insertions(+), 12 deletions(-) rename tests/{test_basic_manipulators_2.py => test_basic_manipulators.py} (64%) create mode 100644 tests/test_decompositions.py diff --git a/tests/test_basic_manipulators_2.py b/tests/test_basic_manipulators.py similarity index 64% rename from tests/test_basic_manipulators_2.py rename to tests/test_basic_manipulators.py index fd2b709..0a7e944 100644 --- a/tests/test_basic_manipulators_2.py +++ b/tests/test_basic_manipulators.py @@ -83,3 +83,38 @@ def test_scalar_factor(self): assert isinstance(scalar_factor, ScalarFactor) assert scalar_factor.full == Matrix([[1, 1], [3, 2]]) assert scalar_factor.diag == Matrix([[2, 0], [0, 4]]) + + def test_hermitian_transpose(self): + """Verify the .H property for complex matrices.""" + A = Matrix([[1, 2 + sym.I], [3 - sym.I, 4]]) + expected = Matrix([[1, 3 + sym.I], [2 - sym.I, 4]]) + assert A.H == expected + + def test_is_mat_orthogonal_behavioral(self): + """Verify is_mat_orthogonal calls .is_diagonal() as a method, not a property.""" + A = Matrix.eye(2) + assert A.is_mat_orthogonal() is True + B = Matrix([[1, 1], [0, 1]]) + assert B.is_mat_orthogonal() is False + + def test_is_mat_orthogonal_negative(self): + """Verify is_mat_orthogonal returns False for non-orthogonal matrices.""" + # Non-square matrix (not orthogonal by definition) + A = Matrix([[1, 0, 0], [0, 1, 0]]) + assert A.is_mat_orthogonal(verbosity=0) is False + # Square but non-orthogonal + B = Matrix([[1, 1], [0, 1]]) + assert B.is_mat_orthogonal(verbosity=0) is False + + def test_scale_row_warning(self): + """Verify that scaling a row by zero triggers a UserWarning.""" + mat = Matrix([[1, 2], [3, 4]]) + with pytest.warns(UserWarning): + mat.scale_row(0, 0) + + def test_normalized_zero_column(self): + """Verify that normalized() handles zero columns without errors.""" + mat = Matrix([[0, 1], [0, 0]]) + result = mat.normalized() + assert result[0, 0] == 0 + assert result[1, 0] == 0 diff --git a/tests/test_decompositions.py b/tests/test_decompositions.py new file mode 100644 index 0000000..d08c8b7 --- /dev/null +++ b/tests/test_decompositions.py @@ -0,0 +1,89 @@ +import pytest +import sympy as sym +from ma1522 import Matrix, SVD, PDP + +pytest.skip("Skipping this entire file because it's under construction", allow_module_level=True) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _reconstruction_norm(svd: SVD, A: Matrix) -> float: + """Numerical Frobenius norm of U S V^T - A.""" + U = svd.U.evalf() + S = svd.S.evalf() + V = svd.V.evalf() + diff = U @ S @ V.T - A.evalf() + return float(sym.re(diff.norm())) + +def _diag_reconstruction_norm_no_inv(pdp: PDP, A: Matrix) -> float: + """Verify A = P D P^{-1} without inverting P: check ||A P - P D|| numerically.""" + P = pdp.P.evalf() + D = pdp.D.evalf() + A_num = A.evalf() + diff = A_num @ P - P @ D + return float(sym.re(diff.norm())) + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestSVDDecomposition: + """Regression and edge-case tests for Singular Value Decomposition.""" + + def test_4x3_irrational_reconstruction(self): + """Test SVD on matrix with irrational eigenvalues (Regression).""" + A = Matrix([[1, -2, -1], [2, 0, 1], [2, -4, 2], [4, 0, 0]]) + # Note: verify=False because the symbolic norm check is extremely expensive for this matrix + svd = A.singular_value_decomposition(verbosity=0, verify=False) + assert _reconstruction_norm(svd, A) < 1e-8 + + def test_rank_deficient_reconstruction(self): + """Test SVD on rank-deficient matrix.""" + A = Matrix([[1, 2], [2, 4]]) + svd = A.singular_value_decomposition(verbosity=0, verify=False) + assert _reconstruction_norm(svd, A) < 1e-8 + + def test_fast_svd_tol_none(self): + """Verify fast_svd handles tol=None correctly (Regression).""" + mat = Matrix([[1.1, 1], [-0.1, 0]]) + svd = mat.fast_svd(option="sym", identify=True, tol=None) + assert isinstance(svd, SVD) + +class TestDiagonalization: + """Regression tests for Matrix diagonalization.""" + + def test_ata_irrational_diagonalization(self): + """Test diagonalization of A^T A with irrational eigenvalues (Regression).""" + A = Matrix([[1, -2, -1], [2, 0, 1], [2, -4, 2], [4, 0, 0]]) + ATA = A.T @ A + pdp = ATA.diagonalize(verbosity=0) + assert _diag_reconstruction_norm_no_inv(pdp, ATA) < 1e-8 + +class TestVectorSpaces: + """Tests for Vector Space operations (Gram-Schmidt, Transition Matrix).""" + + def test_transition_matrix_indexing(self): + """Verify transition_matrix uses correct slicing.""" + B = Matrix([[1, 0], [0, 1]]) + C = Matrix([[1, 1], [1, -1]]) + T = B.transition_matrix(to=C, verbosity=0) + assert T == C.inv() @ B + + def test_gram_schmidt_orthonormal(self): + """Verify Gram-Schmidt returns orthonormal vectors.""" + v1 = Matrix([1, 1, 0]) + v2 = Matrix([1, 0, 0]) + res = Matrix.from_list([v1, v2]).gram_schmidt(factor=False, verbosity=0) + assert res.col(0) == v1.normalized() + assert res.col(1) == (v2 - (v2.dot(v1)/v1.dot(v1))*v1).normalized() + +class TestNegativeDecompositions: + """Negative tests for decompositions (invalid inputs).""" + + def test_diagonalize_non_diagonalizable(self): + """Verify diagonalize raises error for non-diagonalizable matrices.""" + # Shear matrix [[1, 1], [0, 1]] is not diagonalizable + A = Matrix([[1, 1], [0, 1]]) + with pytest.raises(Exception): + A.diagonalize(verbosity=0) diff --git a/tests/test_factory_methods.py b/tests/test_factory_methods.py index f2c5d44..6b7dd1e 100644 --- a/tests/test_factory_methods.py +++ b/tests/test_factory_methods.py @@ -206,6 +206,20 @@ def test_valid_vectors(self, vectors, row_join, expected): result = Matrix.from_list(vectors, row_join) assert result == expected + def test_from_list_non_mutation(self): + """Verify from_list is independent of the input list and its contents (Regression).""" + v1 = Matrix([1, 2]) + v2 = Matrix([3, 4]) + vecs = [v1, v2] + res = Matrix.from_list(vecs) + + # Verify the list itself was not mutated (no .pop() occurred) + assert len(vecs) == 2 + + # Verify defensive copying: changing v1 should NOT change 'res' + v1[0, 0] = 99 + assert res[0, 0] == 1, "Matrix should be independent of future mutations to the input vectors" + @pytest.mark.parametrize( "vectors", [ @@ -285,6 +299,21 @@ def test_create_rand_matrix(self): assert mat.shape == (2, 2) assert mat == Matrix([[81, 14], [3, 94]]) +class TestApplyVander: + """Regression tests for Vandermonde matrix applications.""" + def test_basic_substitution(self): + V = Matrix.create_vander(3, 3) + x_vec = Matrix([[2], [3], [5]]) + result = V.apply_vander(x_vec) + assert result.free_symbols == set() + + def test_free_symbols_not_mutated(self): + V = Matrix.create_vander(3, 3) + syms_before = frozenset(V.free_symbols) + x_vec = Matrix([[2], [3], [5]]) + V.apply_vander(x_vec) + assert frozenset(V.free_symbols) == syms_before + class TestOverriddenFactoryMethods: def test_eye(self): @@ -311,3 +340,8 @@ def test_T_property(self): mat = Matrix([[1, 2], [3, 4]]) assert mat.T == Matrix([[1, 3], [2, 4]]) assert isinstance(mat.T, Matrix) + + def test_H_property(self): + mat = Matrix([[1, 2*sym.I], [3+4*sym.I, 4]]) + assert mat.H == Matrix([[1, 3-4*sym.I], [-2*sym.I, 4]]) + assert isinstance(mat.H, Matrix) \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py index 717c0b2..8865b75 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -33,46 +33,57 @@ def test_latex_wrapping(self): assert _unwrap_latex(None) == "" def test_ipython_detection(self): + """Mock various environments to verify IPython detection logic.""" from ma1522.utils import _is_IPython from unittest.mock import patch, MagicMock - # Test failure (default/standard python) + # Standard Python (get_ipython doesn't exist) with patch("IPython.core.getipython.get_ipython", side_effect=NameError): assert _is_IPython() is False - # Test success (IPython shell) + # Jupyter/ZMQ Shell (Success) with patch("IPython.core.getipython.get_ipython") as mock_get: mock_shell = MagicMock() mock_shell.__class__.__name__ = "ZMQInteractiveShell" mock_get.return_value = mock_shell assert _is_IPython() is True - # Test "Other type" (False) + # Terminal IPython (Success) with patch("IPython.core.getipython.get_ipython") as mock_get: mock_shell = MagicMock() - mock_shell.__class__.__name__ = "SomeOtherShell" + mock_shell.__class__.__name__ = "TerminalInteractiveShell" + mock_get.return_value = mock_shell + assert _is_IPython() is True + + # Unrecognized Shell (Failure) + with patch("IPython.core.getipython.get_ipython") as mock_get: + mock_shell = MagicMock() + mock_shell.__class__.__name__ = "UnknownShell" mock_get.return_value = mock_shell assert _is_IPython() is False - def test_display_ipython(self): + def test_display_ipython_and_fallback(self): from ma1522.utils import display from unittest.mock import patch, MagicMock - # Mock IPython success - with patch("IPython.core.getipython.get_ipython") as mock_get: - mock_shell = MagicMock() - mock_shell.__class__.__name__ = "ZMQInteractiveShell" - mock_get.return_value = mock_shell - + # IPython SUCCESS + with patch("ma1522.utils._is_IPython", return_value=True): with patch("IPython.display.display") as mock_disp: display({"a": 1}, 5, opt="dict") assert mock_disp.called - + display("math expression", opt="math") assert mock_disp.called display("standard") assert mock_disp.called + # IPython ABSENT (Fallback to print) + with patch("ma1522.utils._is_IPython", return_value=False): + with patch("builtins.print") as mock_print: + display("hello world") + assert mock_print.called + # Check if it was called with the right argument + mock_print.assert_any_call("hello world") def test_gen_latex_repr_dict_special(self): from ma1522.utils import _gen_latex_repr_dict From dfcc768000ecbed104c27512aa02c9901998bb77 Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Sun, 10 May 2026 08:48:15 +0800 Subject: [PATCH 07/11] Fix minor bugs --- src/ma1522/symbolic.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ma1522/symbolic.py b/src/ma1522/symbolic.py index bd26e83..fe829da 100644 --- a/src/ma1522/symbolic.py +++ b/src/ma1522/symbolic.py @@ -389,8 +389,8 @@ def from_list( """ if not vectors: return Matrix([]) - res = Matrix(vectors.pop(0)) - for vec in vectors: + res = Matrix(vectors[0]) + for vec in vectors[1:]: if row_join: res = res.row_join(vec, aug_line=False) else: @@ -4315,7 +4315,8 @@ def fast_svd( S = S.identify(tol=tol, suppress_warnings=True) V = V.identify(tol=tol, suppress_warnings=True) residues = (self - U @ S @ V.T).norm() - if residues > tol: + _tol = tol if tol is not None else 1e-15 + if residues > _tol: res = residues.evalf() warn( f"Non-zero Identification Error: {res}", From 4beb171b6314d8e98788799d11a8fc700a435a44 Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Mon, 11 May 2026 19:43:05 +0800 Subject: [PATCH 08/11] Fix bugs and performance issues dicovered in forks - Fix inverse auto-detection for left, right, and square inverses - Fix sep_unk symbol splitting and symbolic scalar_factor verification - Handle zero-subspace checks in is_subspace_of - Use Hermitian projection coefficients in Gram-Schmidt - Optimize full QR basis extension via orthogonal complement - Improve solve_least_squares empty solver-result handling - Avoid unnecessary columnspace calls in diagonalization and SVD paths --- src/ma1522/symbolic.py | 4410 ---------------------------------------- 1 file changed, 4410 deletions(-) delete mode 100644 src/ma1522/symbolic.py diff --git a/src/ma1522/symbolic.py b/src/ma1522/symbolic.py deleted file mode 100644 index fe829da..0000000 --- a/src/ma1522/symbolic.py +++ /dev/null @@ -1,4410 +0,0 @@ -from __future__ import annotations - -from collections import defaultdict -from collections.abc import Iterable -from typing import TYPE_CHECKING -from warnings import warn - -import sympy as sym -from sympy.parsing.sympy_parser import parse_expr - -import numpy as np -import mpmath as mp -from latex2sympy2 import latex2sympy -import re - -# import IPython.display - -from .utils import _is_zero, _standardise_symbol, _textify, display - -from .custom_types import ( - Shape, - PartGen, - ScalarFactor, - PLU, - RREF, - RREFCase, - VecDecomp, - QR, - PDP, - SVD, - NumSVD, -) - -if TYPE_CHECKING: - from typing import Callable, Literal, DefaultDict - -from sympy.core.expr import Expr -from sympy.core.symbol import Symbol -from sympy.core.mul import Mul - -sym.init_printing(use_unicode=True) -np.set_printoptions(formatter={"float": lambda x: f"{x:10.7g}"}) - - -# --------------------------------------------------------------------------- - - -class Matrix(sym.MutableDenseMatrix): - r"""A symbolic matrix class extending [`MutableDenseMatrix`][sympy.matrices.dense.MutableDenseMatrix] with enhanced linear algebra operations. - - The inherited methods from [`MutableDenseMatrix`][sympy.matrices.dense.MutableDenseMatrix] - can be found in the [SymPy Matrices Documentation](https://docs.sympy.org/latest/modules/matrices/matrices.html). A summary of the - inherited attributes and methods is also available on the [Inherited Methods Summary](inherited.md) page. - - This class provides comprehensive linear algebra functionality with support for: - - Matrix creation from various sources (lists, $\rm\LaTeX$, random values) - - Matrix decompositions (REF, RREF, LU, QR, SVD, diagonalization) - - Vector space operations (orthogonalization, projections, basis manipulation) - - Eigenvalue/eigenvector computations - - Custom printing and $\rm\LaTeX$ formatting with augmented matrix support - - Key Features: - - Maintains symbolic expressions throughout operations - - Follows MA1522 syllabus conventions for linear algebra - - Provides detailed step-by-step output for learning purposes - - Supports both exact symbolic and numerical computations - - Attributes: - _aug_pos (set[int]): Set of column indices where augmentation lines should be drawn - for displaying augmented matrices. - - Examples: - Basic matrix operations: - >>> A = Matrix([[1, 2], [3, 4]]) - >>> A.rref() - RREF(rref=Matrix([ - [1, 0] - [0, 1] - ]), pivots=(0, 1)) - - Creating from LaTeX: - >>> B = Matrix.from_latex(r'\begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix}') - ⎡1 2⎤ - ⎢ ⎥ - ⎣3 4⎦ - - Eigenvalue decomposition: - >>> P, D = A.diagonalize(verbosity=0) - >>> P, D - (Matrix([ - [-sqrt(33)/6 - 1/2, -1/2 + sqrt(33)/6] - [ 1, 1] - ]), Matrix([ - [5/2 - sqrt(33)/2, 0] - [ 0, 5/2 + sqrt(33)/2] - ])) - """ - - def __init__( - self, - *args, - aug_pos: Iterable[int] | int | None = None, - **kwargs, - ) -> None: - if aug_pos is None: - self._aug_pos = set() - elif isinstance(aug_pos, int): - self._aug_pos = set([aug_pos]) - elif isinstance(aug_pos, Iterable) and all(isinstance(i, int) for i in aug_pos): - self._aug_pos = set(aug_pos) - else: - raise TypeError( - f"Invalid type for aug_pos: {type(aug_pos)}. Expected Iterable[int]." - ) - - def __str__(self) -> str: - res = super().__str__() - if not hasattr(self, "_aug_pos"): - # Matrices produced by parent methods may not have _aug_pos - return res - aug_pos = f"aug_pos: {self._aug_pos}" - return f"{res}, {aug_pos}" - - def __repr__(self) -> str: - if not hasattr(self, "_aug_pos"): - # Matrices produced by parent methods may not have _aug_pos - return super().__repr__() - - def rep_row(row: str, pos_set: set[int]) -> str: - repr = "" - elems = row.removesuffix(",").split(",") - for idx, elem in enumerate(elems): - if idx in pos_set: - repr += elem + " |" - else: - repr += elem + "," - return repr.removesuffix("|").removesuffix(",") - - res = super().__repr__().removeprefix("Matrix([").removesuffix("])") - res_row_list = (rep_row(row, self._aug_pos) for row in res.split("\n")) - return "Matrix([" + "\n".join(res_row_list) + "\n])" - - def __eq__(self, other) -> bool: - if not hasattr(self, "_aug_pos") or not hasattr(other, "_aug_pos"): - return super().__eq__(other) - - return super().__eq__(other) and (self._aug_pos == other._aug_pos) - - # Override - def _latex(self, printer=None) -> str: - if printer is None: - raw = sym.latex(sym.Matrix(self)) - else: - raw = printer._print(sym.Matrix(self)) # type: ignore - - if not hasattr(self, "_aug_pos"): - # Matrices produced by parent methods may not have _aug_pos - return raw - - # get latex representation of matrix with "array" format - raw = sym.latex(sym.Matrix(self), mat_str="array") - array_c = "\\begin{array}{" + "c" * self.cols + "}" - for mat_str in ["smallmatrix", "matrix"]: - old_beg = "\\begin{" + mat_str + "}" - old_end = "\\end{" + mat_str + "}" - raw = raw.replace(old_beg, array_c).replace(old_end, "\\end{array}") - - ls = [ - pos for pos in self._aug_pos if 0 <= pos < self.cols - 1 - ] # remove trailing pos - if len(ls) == 0: - # no valid _aug_pos found - return raw - ls.sort() - - # create formatting string s to insert augment line visually - delta = [ls[0]] - delta.extend([ls[i] - ls[i - 1] for i in range(1, len(ls))]) - remainder = self.cols - sum(delta) - 1 - delta.append(remainder) - s = "\\begin{array}{c" + "|".join(("c" * i for i in delta)) + "}" - array_c = "\\begin{array}{" + "c" * self.cols + "}" - - return raw.replace(array_c, s) - - ################### - # FACTORY METHODS # - ################### - @staticmethod - def from_latex( - expr: str, - verbosity: int = 1, - row_join: bool = True, - norm: bool = False, - aug_pos: Iterable[int] | int | None = None, - ) -> Matrix: - r"""Converts a $\rm\LaTeX$ matrix/vector expression into a Matrix object. - - Parses $\rm\LaTeX$ matrix environments (pmatrix, array) and vector lists into a Matrix. - Handles matrix multiplication expressions and normalizes vectors when requested. - - Args: - expr (str): $\rm\LaTeX$ string containing: - - - Matrix environments: - `\begin{pmatrix} ... \end{pmatrix}`, `\begin{array}{ccc} ... \end{array}` - - Vector lists: - `\{ \begin{pmatrix} ... \end{pmatrix}, \begin{pmatrix} ... \end{pmatrix}\}` - - Matrix products: - `\begin{pmatrix}...\end{pmatrix}\begin{pmatrix} ... \end{pmatrix}` - - verbosity (int): Controls output detail level: - - 0: No output - - 1: Display parsed matrix - - row_join (bool): If True, vector lists are treated as columns. - If False, vectors are treated as rows. - - norm (bool): If True, normalizes vectors to unit length. - - aug_pos (Iterable[int] | int | None): If provided, specifies the column indices - where augmentation lines should be drawn in the output matrix. This is useful for displaying - augmented matrices in a visually clear manner. If `None`, no augmentation lines are drawn. - - Returns: - (Matrix): The parsed matrix with optional normalization. - - Raises: - Exception: If the $\rm\LaTeX$ expression is empty, invalid or cannot be parsed. - - Examples: - >>> Matrix.from_latex(r'\begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix}', verbosity=0) - Matrix([ - [1, 2] - [3, 4] - ]) - - >>> Matrix.from_latex(r'\begin{pmatrix} 1 \\ 2 \\ 3 \end{pmatrix}', verbosity=0, norm=True) - Matrix([ - [ sqrt(14)/14] - [ sqrt(14)/7] - [3*sqrt(14)/14] - ]) - """ - - # Step 1: Modify the LaTeX string to ensure compatibility with the parser. - # Convert array-like LaTeX to pmatrix for proper matrix formatting - # Replace \begin{array}{ccc*} with \begin{pmatrix} - modified_latex = re.sub( - r"\\begin\{array\}(?:\{[^}]*\})?", r"\\begin{pmatrix}", expr - ) - # Replace \end{array} with \end{pmatrix} - modified_latex = re.sub(r"\\end\{array\}", r"\\end{pmatrix}", modified_latex) - # Remove LaTeX semicolon for cleaner parsing - modified_latex = re.sub(r"\\;", "", modified_latex) - - # Step 2: Use latex2sympy to parse the modified LaTeX expression into SymPy Matrix - res = latex2sympy(modified_latex) - if verbosity >= 1: - display(res) - - # Step 3: Handle the parsed result based on its type (list, MatMul, or Matrix) - mat = Matrix() - if isinstance(res, list): - vector_list = [] - for vector in res: - vector = vector.expand() - vector_list.append(vector) - mat = Matrix.from_list(vector_list, row_join) - elif isinstance(res, sym.MatMul): - # If the matrix is a product of matrices, evaluate the product directly - mat = Matrix(res.doit()) - elif isinstance(res, sym.Matrix): - # Directly converts the SymPy Matrix into the custom Matrix object to inherit the custom methods - mat = Matrix(res) - else: - # If the result is neither a list nor a matrix expression, return the raw result - mat = res - - if aug_pos is not None: - mat = Matrix(mat, aug_pos=aug_pos) - - # Step 4: Normalisation - if norm and isinstance(mat, Matrix): - return mat.normalized(factor=False) # type: ignore - return mat - - @staticmethod - def from_str( - matrix_str: str, - row_sep: str = ";", - col_sep: str = " ", - aug_pos: Iterable[int] | int | None = None, - is_real: bool = True, - ) -> Matrix: - """Parses a string representation of a matrix into a Matrix. - - This method enables quick creation of a Matrix object from a string format similar to - the one used in MATLAB. It supports both row and column separators and - uses SymPy's [`parse_expr`][sympy.parsing.sympy_parser.parse_expr] to convert - the entries of the matrix from a string format into a Matrix object. - - Args: - matrix_str (str): The string representation of the matrix. - row_sep (str, optional): The separator for rows in the string. - col_sep (str): The separator for columns in the string. - aug_pos (Iterable[int] | int | None, optional): If provided, specifies the column indices - where augmentation lines should be drawn in the output matrix. This is useful for displaying - augmented matrices in a visually clear manner. If `None`, no augmentation lines are drawn. - is_real (bool, optional): If True, the symbols in the matrix are considered real-valued. Otherwise, they are complex. - - Returns: - (Matrix): A Matrix object representing the parsed matrix. - - Raises: - SyntaxError: If the string cannot be parsed into a matrix. - - Examples: - >>> Matrix.from_str("[1 2; 3 4]") - Matrix([ - [1, 2] - [3, 4] - ]) - """ - - matrix_str = ( - matrix_str.strip().removeprefix("[").removesuffix("]") - ) # remove surrounding brackets - rows = matrix_str.strip().split(row_sep) - matrix = [] - for row in rows: - cols = row.strip().split(col_sep) - matrix.append([parse_expr(col.strip()) for col in cols]) - - if aug_pos is not None: - matrix = Matrix(matrix, aug_pos=aug_pos) - else: - matrix = Matrix(matrix) - - symbols = matrix.free_symbols - new_symbols = _standardise_symbol(symbols, is_real=is_real) - matrix = matrix.subs({s: n for s, n in zip(symbols, new_symbols)}) - return matrix - - @staticmethod - def from_list( - vectors: list[Matrix], - row_join: bool = True, - aug_pos: Iterable[int] | int | None = None, - ) -> Matrix: - """Creates a Matrix object from a list of vectors. - - This method takes a list of vectors (each represented as a Matrix object) - and combines them into a single matrix. - - Args: - vectors (list[Matrix]): A list of Matrix objects, where each Matrix - represents a row or column vector. - - row_join (bool, optional): If True, the vectors are joined horizontally. - If False, the vectors are stacked vertically. - - aug_pos (Iterable[int] | int | None, optional): If provided, specifies the column indices - where augmentation lines should be drawn in the output matrix. This is useful for displaying - augmented matrices in a visually clear manner. If `None`, no augmentation lines are drawn. - - Returns: - (Matrix): A matrix constructed from the list of vectors. - - Raises: - sympy.matrices.exceptions.ShapeError: If the vectors do not have compatible dimensions for joining. - - Examples: - >>> vec1 = Matrix([[1], [2]]) - >>> vec2 = Matrix([[3], [4]]) - >>> Matrix.from_list([vec1, vec2]) - Matrix([ - [1, 3] - [2, 4] - ]) - - >>> Matrix.from_list([vec1, vec2], row_join=False) - Matrix([ - [1] - [2] - [3] - [4] - ]) - """ - if not vectors: - return Matrix([]) - res = Matrix(vectors[0]) - for vec in vectors[1:]: - if row_join: - res = res.row_join(vec, aug_line=False) - else: - res = res.col_join(vec) - if aug_pos is not None: - return Matrix(res, aug_pos=aug_pos) - return res - - def _shape(self, shape: Shape) -> Matrix: - match shape: - case Shape.DIAGONAL: - res = Matrix.diag(*self.diagonal()) - if self.rows > self.cols: - res = res.col_join(Matrix.zeros(self.rows - self.cols, self.cols)) - elif self.rows < self.cols: - res = res.row_join( - Matrix.zeros(self.rows, self.cols - self.rows), aug_line=False - ) - return res - case Shape.SCALAR: - if self.rows != self.cols: - raise sym.NonSquareMatrixError( - "Scalar shape is only defined for square matrices." - ) - return self.diagonal()[0] * self.elem() - case Shape.UPPER: - return self.upper_triangular() - case Shape.LOWER: - return self.lower_triangular() - case Shape.STRICT_UPPER: - return self._shape(Shape.UPPER) - self._shape(Shape.DIAGONAL) - case Shape.STRICT_LOWER: - return self._shape(Shape.LOWER) - self._shape(Shape.DIAGONAL) - case Shape.SYMMETRIC: - if self.rows != self.cols: - raise sym.NonSquareMatrixError( - "Symmetric shape is only defined for square matrices." - ) - return self._shape(Shape.UPPER) + self._shape(Shape.STRICT_UPPER).T - - @staticmethod - def create_unk_matrix( - r: int = 1, - c: int = 1, - symbol: str | None = None, - is_real: bool | None = True, - shape: Shape | None = None, - ) -> Matrix: - r"""Creates a symbolic matrix with unknown entries. - - This method generates a matrix of size $r \times c$ with symbolic - entries. The entries are named based on the provided `symbol` parameter and - indexed by their row and column positions. The `is_real` flag determines whether - the symbols are real-valued. - - Note: - - For a column vector without a specified symbol, the entries will be named - following conventions, i.e., $\begin{pmatrix} x \end{pmatrix}$, - $\begin{pmatrix} x \\ y \end{pmatrix}$, - $\begin{pmatrix} x \\ y \\ z \end{pmatrix}$, for 1, 2 and 3 rows respectively. - - Args: - r (int, optional): The number of rows in the matrix. - c (int, optional): The number of columns in the matrix. - symbol (str, optional): The base name for the symbols used in the matrix entries. - is_real (bool, optional): If True, the symbols are real-valued. Otherwise, they are complex. - shape (Shape, optional): If provided, the matrix will be reshaped to this - specific shape. Supported shapes include: - - - [DIAGONAL][(p).Shape.DIAGONAL]: Returns a diagonal matrix. - - [SCALAR][(p).Shape.SCALAR]: Returns a scalar matrix. - - [UPPER][(p).Shape.UPPER]: Returns an upper triangular matrix. - - [LOWER][(p).Shape.LOWER]: Returns a lower triangular matrix. - - [STRICT_UPPER][(p).Shape.STRICT_UPPER]: Returns an upper triangular matrix without the diagonal. - - [STRICT_LOWER][(p).Shape.STRICT_LOWER]: Returns a lower triangular matrix without the diagonal. - - [SYMMETRIC][(p).Shape.SYMMETRIC]: Returns a symmetric matrix. - - Returns: - (Matrix): A matrix with symbolic entries of the specified size. - - Raises: - sympy.matrices.exceptions.NonSquareMatrixError: If `shape` is ill-defined on a non-square matrix. - - Examples: - >>> Matrix.create_unk_matrix(2, 2, symbol='a') - Matrix([ - [a_1,1, a_1,2], - [a_2,1, a_2,2]]) - - >>> Matrix.create_unk_matrix(3, 1, symbol='y') - Matrix([ - [y_1] - [y_2] - [y_3] - ]) - - See Also: - - [`create_rand_matrix`][..]: Creates a matrix with random entries. - """ - - # Create a vector of size rows with entries (x, y) or (..., y, z) - if r <= 26 and c == 1 and symbol is None: - ls = [] - match r: - case 1: - ls = list("x") - case 2: - ls = list("xy") - case 3: - ls = list("xyz") - case _: - ascii_lowercase = list("abcdefghijklmnopqrstuvwxyz") - ls = ascii_lowercase[26 - r :] - entries = sym.symbols(ls, real=is_real) - return Matrix(entries) - - if symbol and c == 1: - entries = sym.symbols(f"{symbol}_(1:{r + 1})", real=is_real) - return Matrix(entries) - - # Creates a matrix of size rows * cols with entries symbol_i,j - symbol = symbol or "x" # default is "x" - entries = sym.symbols(f"{symbol}_(1:{r + 1})\\,(1:{c + 1})", real=is_real) - - res = Matrix(entries).reshape(r, c) - if shape: - return res._shape(shape) - else: - return res - - @staticmethod - def create_rand_matrix( - r: int = 1, - c: int = 1, - shape: Shape | None = None, - *args, - **kwargs, - ) -> Matrix: - r"""Creates a matrix with random entries. - - This method generates a matrix of size $r \times c$ where the - entries are real integers. The values in the matrix are generated using SymPy's - [`randMatrix`][sympy.matrices.dense.randMatrix] function. - - Note: - - The entries in the matrix are generated randomly and will change each time - the function is called. Setting a random seed using `seed` in `**kwargs` - will ensure reproducibility of the random values. - - Args: - r (int, optional): The number of rows in the matrix. - c (int, optional): The number of columns in the matrix. - shape (Shape, optional): If provided, the matrix will be reshaped to this - specific shape. Supported shapes include: - - - [DIAGONAL][(p).Shape.DIAGONAL]: Returns a diagonal matrix. - - [SCALAR][(p).Shape.SCALAR]: Returns a scalar matrix. - - [UPPER][(p).Shape.UPPER]: Returns an upper triangular matrix. - - [LOWER][(p).Shape.LOWER]: Returns a lower triangular matrix. - - [STRICT_UPPER][(p).Shape.STRICT_UPPER]: Returns an upper triangular matrix without the diagonal. - - [STRICT_LOWER][(p).Shape.STRICT_LOWER]: Returns a lower triangular matrix without the diagonal. - - [SYMMETRIC][(p).Shape.SYMMETRIC]: Returns a symmetric matrix. - *args: Additional arguments passed to the [`randMatrix`][sympy.matrices.dense.randMatrix] - function. - **kwargs: Additional arguments passed to the [`randMatrix`][sympy.matrices.dense.randMatrix] - function. - - Returns: - (Matrix): A Matrix with random entries of the specified size. - - Raises: - sympy.matrices.exceptions.NonSquareMatrixError: If `shape` is ill-defined on a non-square matrix. - - Examples: - >>> Matrix.create_rand_matrix(2, 3, seed=42) - Matrix([ - [81, 14, 3] - [94, 35, 31] - ]) - - See Also: - - [`create_unk_matrix`][..]: Creates a matrix with symbolic entries. - - [`randMatrix`][sympy.matrices.dense.randMatrix]: SymPy function to create a random matrix. - """ - res = Matrix(sym.randMatrix(*args, r=r, c=c, **kwargs)) - if shape: - return res._shape(shape) - else: - return res - - # Override - @staticmethod - def eye(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: - return Matrix(sym.eye(*args, **kwargs), aug_pos=aug_pos) - - # Override - @staticmethod - def zeros(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: - return Matrix(sym.zeros(*args, **kwargs), aug_pos=aug_pos) - - # Override - @staticmethod - def ones(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: - return Matrix(sym.ones(*args, **kwargs), aug_pos=aug_pos) - - # Override - @staticmethod - def diag(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: - return Matrix(sym.diag(*args, **kwargs), aug_pos=aug_pos) - - # Override - @property - def H(self): - """Returns the Hermitian transpose (conjugate transpose) of the matrix. - - This overrides the default behavior where .H calls .adjoint(), which in this - class is overridden to return the adjugate matrix. - """ - return self.T.conjugate() - - # Override - @property - def T(self) -> Matrix: - return Matrix(super().T) - - ################################################### - # OVERRIDE OVERLOADED PYTHON ARITHMETIC OPERATORS # - ################################################### - - def __abs__(self) -> Matrix: - aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() - return Matrix(super().__abs__(), aug_pos=aug) - - def __add__(self, other: Matrix) -> Matrix: - aug_self = getattr(self, "_aug_pos", set()) - aug_other = getattr(other, "_aug_pos", set()) - aug = aug_self | aug_other - return Matrix(super().__add__(other), aug_pos=aug) - - def __mul__(self, other) -> Matrix: - res = super().__mul__(other) - if hasattr(other, "shape") and self.shape != res.shape: - return Matrix(res) - else: - # probably scalar multiplication - aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() - return Matrix(res, aug_pos=aug) - - def __rmul__(self, other) -> Matrix: - res = super().__rmul__(other) - if hasattr(other, "shape") and self.shape != res.shape: - return Matrix(res) - else: - # probably scalar multiplication - aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() - return Matrix(res, aug_pos=aug) - - def __neg__(self) -> Matrix: - aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() - return Matrix(super().__neg__(), aug_pos=aug) - - ###################### - # BASIC MANIPULATORS # - ###################### - - # Override - def copy(self) -> Matrix: - """ - Creates a copy of the matrix, preserving augmentation lines. - - This method returns a new [`Matrix`][...] object that is a deep copy of the current matrix, - including any augmentation line positions (used for displaying augmented matrices). - - Returns: - (Matrix): A new matrix object with the same entries and augmentation lines as the original. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]], aug_pos={1}) - >>> mat_copy = mat.copy() - >>> mat_copy == mat - True - >>> mat_copy is mat - False - """ - new_mat = super().copy() - aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() - return Matrix(new_mat, aug_pos=aug) - - # Override - def subs(self, *args, **kwargs) -> Matrix: - """Substitutes values in the matrix entries. - - This method overrides SymPy's [`subs`][sympy.matrices.matrixbase.MatrixBase.subs] - method to ensure that the augmentation lines are preserved after substitution. - - Args: - *args: Positional arguments for substitution. - **kwargs: Keyword arguments for substitution. - - Returns: - (Matrix): A new matrix with substituted values, preserving augmentation lines. - - Examples: - >>> x = sym.Symbol('x') - >>> mat = Matrix([[x, 1]], aug_pos=0) - >>> mat.subs({x: 2}) == Matrix([[2, 1]], aug_pos=0) - True - """ - new_mat = super().subs(*args, **kwargs) - aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() - return Matrix(new_mat, aug_pos=aug) - - # Override - def simplify( - self, - rational: bool = True, - suppress_warnings: bool = False, - tolerance: float = 1e-4, - simplify: bool = True, - expand: bool = True, - collect_sym: Symbol | None = None, - *args, - **kwargs, - ) -> None: - """Simplifies the matrix by applying various simplification techniques. - - This method performs several operations on the matrix to simplify its entries: - - Rational simplification. - - General symbolic simplification. - - Expansion or factoring of expressions. - - Collecting terms involving a specific symbol (if provided). - - Note: - - Rational simplification attempts to convert entries into rational numbers if possible. - If there is a residue (e.g. attempting to convert a non-rational number into a rational), - a warning is printed with the approximation error. - - Expansion and factoring can be controlled by the `expand` parameter. - - The matrix is modified in place. - - Args: - rational (bool, optional): If True, applies rational simplification - to the matrix entries using [`sym.nsimplify`][sympy.simplify.simplify.nsimplify]. - suppress_warnings (bool, optional): If True, suppresses warnings about non-zero residues - after rational simplification. - tolerance (float, optional): The tolerance for rational simplification. - simplify (bool, optional): If True, applies general symbolic simplification using [`sym.simplify`][sympy.simplify.simplify.simplify]. - expand (bool, optional): If True, applies expansion to the matrix entries. If False, applies factoring instead. - collect_sym (Symbol, optional): A symbol to collect terms with. If provided, - [`sym.collect`][sympy.simplify.radsimp.collect] will be applied to all entries of the matrix with respect to this symbol. - *args: Additional arguments passed to the [`sym.simplify`][sympy.simplify.simplify.simplify] function. - **kwargs: Additional arguments passed to the [`sym.simplify`][sympy.simplify.simplify.simplify] function. - - Returns: - (Matrix): A new simplified matrix with the applied operations. - - Examples: - >>> mat = Matrix([[sym.symbols('x') + 1, sym.symbols('x') + 2], [sym.symbols('x') + 3, sym.symbols('x') + 4]]) - >>> mat.simplify(rational=False, expand=True) - >>> mat - Matrix([ - [x + 1, x + 2] - [x + 3, x + 4] - ]) - """ - - temp = self.copy() - if rational: - temp = sym.nsimplify(temp, tolerance=tolerance, rational=True) - residues = (temp - self).norm() - if residues != 0 and not suppress_warnings: - res = residues.evalf() - warn( - f""" - Non-zero Approximation Error: {res} - Rational approximation might have failed. Try lower tolerance.""", - RuntimeWarning, - stacklevel=2, - ) - if simplify: - temp = sym.simplify(temp, *args, **kwargs) - if expand: - temp = sym.expand(temp) - else: - temp = temp.applyfunc(lambda x: sym.factor(x)) - if collect_sym is not None: - temp = temp.applyfunc(lambda x: sym.collect(x, collect_sym)) - - # Create a new Matrix object from the simplified list and update the original object - aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() - temp = Matrix(temp, aug_pos=aug) # prevent SymPy from losing _aug_pos - self.__dict__.update(temp.__dict__) - - def identify( - self, tol: float | None = None, suppress_warnings: bool = False, *args, **kwargs - ) -> Matrix: - r"""Identifies the matrix by applying a transformation function to each entry. - - This method applies a transformation to each element of the matrix using - the [`identify`][mpmath.identify] function from the `mp` module. After identification, - the method checks if there is any residue (i.e., if the matrix has been modified). - - Note: - - If there is a residue (i.e., unable to identify an entry, such as $\pi$), - a warning is printed with the approximation error. This can be resolved by - lowering `tolerance` or supplying appropriate `constants` as `**kwargs` to - the `identify` function. - - Args: - tol (float, optional): A tolerance value that is passed to the - [`identify`][mpmath.identify] function. If None, no tolerance is applied. - suppress_warnings (bool, optional): If True, suppresses warnings about non-zero residues - after identification. - *args: Additional positional arguments passed to the - [`identify`][mpmath.identify] function. - **kwargs: Additional keyword arguments passed to the - [`identify`][mpmath.identify] function. - - Returns: - (Matrix): A new matrix that results from applying the transformation to - each element of the original matrix. - - Examples: - >>> import math - >>> import pytest - >>> mat = Matrix([[math.sqrt(4), math.e], [1/math.sqrt(2), 0.0]]) - >>> with pytest.warns(RuntimeWarning, match="Non-zero Identification Error"): - ... result = mat.identify() - >>> result == Matrix([[2, sym.E], [sym.sqrt(2) / 2, 0]]) - True - - See Also: - - [`mpmath.identify`][mpmath.identify]: The function used to identify - the entries of the matrix. - - [`simplify`][..simplify]: - For general simplification of the matrix entries. - """ - - temp = self.applyfunc(lambda x: mp.identify(x, tol=tol, *args, **kwargs)) - residues = (temp - self).norm() - if residues != 0 and not suppress_warnings: - res = residues.evalf() - warn(f"Non-zero Identification Error: {res}", RuntimeWarning, stacklevel=2) - return temp - - def select_cols(self, *args: int) -> Matrix: - """Selects columns from the matrix based on the provided column indices. - - This method returns a new matrix consisting of the columns specified by the - provided indices. The columns are selected from the original matrix, and the - result is returned as a new matrix. - - Args: - *args (int): One or more column indices (0-based) to select from the matrix. - - Returns: - (Matrix): A new matrix consisting of the selected columns. - - Examples: - >>> mat = Matrix([[1, 2, 3], [4, 5, 6]]) - >>> mat.select_cols(0, 2) - Matrix([ - [1, 3] - [4, 6] - ]) - """ - - res = [] - for idx in args: - res.append(list(self.col(idx))) - return Matrix(res).T - - def select_rows(self, *args: int) -> Matrix: - """Selects rows from the matrix based on the provided row indices. - - This method returns a new matrix consisting of the rows specified by the - provided indices. The rows are selected from the original matrix, and the - result is returned as a new matrix. - - Args: - *args (int): One or more row indices (0-based) to select from the matrix. - - Returns: - (Matrix): A new matrix consisting of the selected rows. - - Examples: - >>> mat = Matrix([[1, 2, 3], [4, 5, 6]]) - >>> mat.select_rows(0) - Matrix([[1, 2, 3] - ]) - """ - - res = [] - for idx in args: - res.append(list(self.row(idx))) - return Matrix(res) - - def sep_part_gen(self) -> PartGen: - """ - Separates a matrix into its particular and general solution parts. - - This method separates the matrix into two components: - - The **particular solution**, which is the solution to the system when - all free variables are set to zero. - - The **general solution**, which is the full solution including the - homogeneous part. - - It assumes that the matrix is in symbolic form and contains free variables that can be set to zero. - - Returns: - (PartGen): A dataclass containing two matrices: - - - `part_sol` ([`Matrix`][...]): The particular solution - (with free variables set to zero). - - `gen_sol` ([`Matrix`][...]): The general solution (the original matrix - minus the particular solution). - - Examples: - >>> from sympy import symbols - >>> x = symbols('x') - >>> mat = Matrix([[x + 2, 3], [3*x, x - 2]]) - >>> PG = mat.sep_part_gen() - >>> PG.part_sol # Particular solution - Matrix([ - [2, 3] - [0, -2] - ]) - >>> PG.gen_sol # General solution - Matrix([ - [ x, 0] - [3*x, x] - ]) - """ - - set_0 = dict(((symbol, 0) for symbol in self.free_symbols)) - part_sol = self.subs(set_0) - gen_sol = self - part_sol - return PartGen(part_sol, gen_sol) - - def sep_unk(self) -> dict[Expr, Matrix]: - """Separates the matrix into matrices with each free symbol set to 1. - - Returns: - (dict[Expr, Matrix]): Returns a dictionary where the sum of the key*value pairs - reconstructs the original matrix. Each key is a free symbol, and each value is a - matrix with that symbol set to 1 and all other free symbols set to 0. - - Examples: - >>> x, y = sym.symbols('x y') - >>> mat = Matrix([[x + y]]) - >>> parts = mat.sep_unk() - >>> parts[x] == Matrix([[1]]) - True - >>> parts[y] == Matrix([[1]]) - True - """ - syms = self.free_symbols - res: dict[Expr, Matrix] = defaultdict(Matrix) - for s in syms: - sub = dict(((symbol, 0) for symbol in syms if symbol != sym)) - sub[s] = 1 # type: ignore - res[s] = self.subs(sub) - return res - - def scalar_factor(self, column: bool = True) -> ScalarFactor: - r"""Factorizes a matrix into the form $\mathbf{A} = \mathbf{FD}$, where $\mathbf{D}$ is a diagonal matrix - and $\mathbf{F}$ contains the vectors with common divisors factored out (if `column=True`). If `column=False`, - then returns $\mathbf{A} = \mathbf{DF}$ instead. - - Args: - column (bool): If `True`, factorizes by columns. If `False`, factorizes by rows. - - Returns: - (ScalarFactor): A dataclass of two matrices (F, D) and order (FD or DF) - - - `diag` ([`Matrix`][...]): The diagonal matrix containing the common divisors. - - `full` ([`Matrix`][...]): The matrix with common divisors factored out. - - `order` (str): The order of the factorization, either "FD" (for column factorization) or "DF" (for row factorization). - - Examples: - >>> mat = Matrix([[6, 9], [12, 15]]) - >>> SF = mat.scalar_factor(column=True) - >>> SF.full, SF.diag - (Matrix([ - [1, 3] - [2, 5] - ]), Matrix([ - [6, 0] - [0, 3] - ])) - """ - - def prettify(scalar: Expr) -> Expr: - num, den = sym.fraction(scalar) - if den == 1: - # return non-fractions as it is - return num - # for scalar in the form sqrt(x * k**2) / x, return k / sqrt(x) - # most notable case is sqrt(x) / x, which should return 1/sqrt(x) - k = sym.sqrt(num / sym.sqrt(den), evaluate=True) # type: ignore - if k.is_integer: # type: ignore - with sym.evaluate(False): - return k / sym.sqrt(den) # type: ignore - else: - return scalar - - scalars = [] - F = self.copy() - if column: - for i in range(self.cols): - g = sym.gcd(tuple(self.col(i))) - F[:, i] /= g - scalars.append(prettify(g)) - D = Matrix.diag(*scalars) - assert self == (F @ D).doit(), "Matrix factorization failed." - return ScalarFactor(diag=D, full=F, order="FD") - else: - for i in range(self.rows): - g = sym.gcd(tuple(self.row(i))) - F[i, :] /= g - scalars.append(prettify(g)) - D = Matrix.diag(*scalars) - assert self == (D @ F).doit(), "Matrix factorization failed." - return ScalarFactor(diag=D, full=F, order="DF") - - ############################# - # CHAPTER 1: LINEAR SYSTEMS # - ############################# - - def aug_line(self, pos: int = -1) -> Matrix: - """Inserts an augmented line at the specified position. - - This method adds an augmented line (i.e., a visual vertical line) - to the matrix at the specified column position. If no position is provided (default: -1), - the line is inserted at the last column. - - Note: - - The method updates the `_aug_pos` attribute to track the position of the inserted line. - - Negative `pos` will be converted before inserting it into `_aug_pos`. - - Args: - pos (int, optional): - The position (column index) where the augmented line will be inserted. - Default -1 means the augmented line is added at the end of the matrix. - - Returns: - (Matrix): The current matrix with the augmented line added at the specified position. - - Raises: - IndexError: If the `pos` is out of range. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.aug_line(0) - Matrix([ - [1 | 2] - [3 | 4] - ]) - - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.aug_line().row_join(mat) - Matrix([ - [1, 2 | 1, 2] - [3, 4 | 3, 4] - ]) - - See Also: - - [`rm_aug_line`][..]: Removes an augmentation line from the matrix. - """ - - new_pos = pos - if new_pos < 0: - new_pos += self.cols - - if not 0 <= new_pos < self.cols: - raise IndexError( - f"Position for augmented line ({pos}) out of range ({self.cols})." - ) - - if not hasattr(self, "_aug_pos"): - self._aug_pos: set[int] = set() - self._aug_pos.add(new_pos) - return self - - def rm_aug_line(self, pos: int | None = None) -> Matrix: - """Remove an augmentation line from the matrix. - - Removes the specified position from the matrix's augmentation line tracking. - If the matrix has no augmentation lines tracked or the position is not - currently marked as an augmentation line, the matrix remains unchanged. - - Note: - This method modifies the matrix's internal `_aug_pos` attribute which - tracks augmentation line positions. If no `_aug_pos` attribute exists, - it will be initialized as an empty set. - - Args: - pos (int, optional): The column position of the augmentation line to remove. - If it is not set, all augmentation lines will be removed - - Returns: - (Matrix): The matrix instance (supports method chaining). - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]], aug_pos=0) - >>> mat.rm_aug_line(0) - Matrix([ - [1, 2] - [3, 4] - ]) - """ - if not hasattr(self, "_aug_pos") or pos is None: - self._aug_pos = set() - return self - if pos in self._aug_pos: - self._aug_pos.remove(pos) - return self - - # Override - def row_join(self, other: Matrix, aug_line: bool = True) -> Matrix: - """Joins two matrices horizontally (column-wise), preserving augmentation lines. - - This method concatenates the columns of `self` and `other` to form a new matrix. Any augmentation lines - (vertical lines for augmented matrices) tracked in either matrix are preserved and adjusted for the new column positions. - - Args: - other (Matrix): The matrix to join to the right of `self`. - aug_line (bool, optional): If `True`, adds an augmentation line between the two matrices. - - Returns: - (Matrix): A new matrix formed by joining `self` and `other` column-wise, with updated augmentation lines. - - Raises: - sympy.matrices.exceptions.ShapeError: If the number of rows in `self` and `other` do not match. - - Examples: - >>> A = Matrix([[1, 2], [3, 4]]) - >>> B = Matrix([[5], [6]]) - >>> A.row_join(B) - Matrix([ - [1, 2 | 5] - [3, 4 | 6] - ]) - """ - aug = set(getattr(self, "_aug_pos", set())) - other_aug = set(getattr(other, "_aug_pos", set())) - offset = self.cols - for pos in other_aug: - aug.add(pos + offset) - if aug_line: - aug.add(offset - 1) - return Matrix(super().row_join(other), aug_pos=aug) - - # Override - def col_join(self, other: Matrix) -> Matrix: - """ - Joins two matrices vertically (row-wise), preserving augmentation lines. - - This method concatenates the rows of `self` and `other` to form a new matrix. Only augmentation lines - (vertical lines for augmented matrices) that are present in both matrices at the same column positions are preserved. - - Note: - - Both `self` and `other` matrices should have the same number of columns for the join to be valid. - - The method updates the `_aug_pos` attribute to include only those positions that are common - in both matrices, ensuring that the augmentation lines are correctly aligned after the join. - - Args: - other (Matrix): The matrix to join below `self`. - - Returns: - (Matrix): A new matrix formed by joining `self` and `other` row-wise, with preserved augmentation lines. - - Raises: - sympy.matrices.exceptions.ShapeError: If the number of columns in `self` and `other` do not match. - - Examples: - >>> A = Matrix([[1, 2]], aug_pos={0}) - >>> B = Matrix([[3, 4]], aug_pos={0}) - >>> A.col_join(B) - Matrix([ - [1 | 2] - [3 | 4] - ]) - """ - aug_self = set(getattr(self, "_aug_pos", set())) - aug_other = set(getattr(other, "_aug_pos", set())) - aug = aug_self & aug_other - return Matrix(super().col_join(other), aug_pos=aug) - - def scale_row( - self, idx: int, scalar: Expr | float | int, verbosity: int = 2 - ) -> Matrix: - """ - Scales a row of the matrix by a scalar and simplifies the result. - - This method scales a specified row of the matrix by multiplying it with a scalar - and then simplifies the matrix. The result is stored back in the matrix. Optionally, - the method can print information about the row scaling and display the matrix, - depending on the verbosity level. - - Note: - - The method modifies the matrix in-place and returns the updated matrix. - - After scaling the row, the matrix is simplified using [`simplify`][..]. - - Args: - idx (int): The index of the row to scale (0-based). - scalar (Expr, float, int): The scalar by which to multiply the row. - verbosity (int, optional): The level of verbosity for output. - - - 0: No output. - - 1: Print the row scaling operation. - - 2: Print the row scaling operation and display the matrix. - - Returns: - (Matrix): The modified matrix with the scaled row. - - Raises: - IndexError: If the `idx` is out of range for the number of rows in the matrix. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.scale_row(0, 2, verbosity=0) - Matrix([ - [2, 4] - [3, 4] - ]) - """ - - if scalar == 0: - warn("Matrix rows should not be scaled by 0", UserWarning, stacklevel=2) - - scalar = sym.sympify(scalar) - self[idx, :] *= scalar # type: ignore - self.simplify(suppress_warnings=True) - - if verbosity >= 1: - display( - f"\\left({sym.latex(scalar)}\\right) R_{idx + 1} \\rightarrow R_{idx + 1}", - opt="math", - ) - if verbosity >= 2: - display(self) - print("\n") - - return self - - def swap_row(self, idx_1: int, idx_2: int, verbosity: int = 2) -> Matrix: - """Swaps two rows of the matrix. - - This method swaps the contents of two rows in the matrix. The operation is performed - in-place, and the modified matrix is returned. Optionally, the method can print - information about the row swap and display the matrix, depending on the verbosity level. - - Note: - - The method modifies the matrix in-place and returns the updated matrix. - - After performing the row swaps, the matrix is simplified using [`simplify`][..]. - - Args: - idx_1 (int): The index of the first row to swap (0-based). - idx_2 (int): The index of the second row to swap (0-based). - verbosity (int, optional): The level of verbosity for output. - - - 0: No output. - - 1: Print the row swap operation. - - 2: Print the row swap operation and display the matrix. - - Returns: - (Matrix): The modified matrix after the row swap. - - Raises: - IndexError: If the `idx_1` or `idx_2` is out of range for the number of rows in the matrix. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.swap_row(0, 1, verbosity=0) - Matrix([ - [3, 4] - [1, 2] - ]) - """ - - self[idx_1, :], self[idx_2, :] = self[idx_2, :], self[idx_1, :] - - if verbosity >= 1: - display(f"R_{idx_1 + 1} \\leftrightarrow R_{idx_2 + 1}", opt="math") - if verbosity >= 2: - display(self) - print("\n") - - return self - - def reduce_row( - self, idx_1: int, scalar: Expr | float | int, idx_2: int, verbosity: int = 2 - ) -> Matrix: - """Reduces a row by subtracting a scalar multiple of another row. - - This method modifies a row by subtracting a specified scalar multiple of another row. - The result is stored back in the matrix. Optionally, the method can print information - about the row reduction and display the matrix, depending on the verbosity level. - - Note: - - The method modifies the matrix in-place and returns the updated matrix. - - After performing the row reduction, the matrix is simplified using [`simplify`][..]. - - Args: - idx_1 (int): The index of the row to reduce (0-based). - scalar (Expr, float, int): The scalar by which to multiply the second row. - idx_2 (int): The index of the row from which to subtract the scalar multiple (0-based). - verbosity (int, optional): The level of verbosity for output. - - - 0: No output. - - 1: Print the row reduction operation. - - 2: Print the row reduction operation and display the matrix. - - Returns: - (Matrix): The modified matrix after the row reduction. - - Raises: - IndexError: If the `idx_1` or `idx_2` is out of range for the number of rows in the matrix. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.reduce_row(0, 2, 1, verbosity=0) - Matrix([ - [-5, -6] - [ 3, 4] - ]) - """ - - scalar = sym.sympify(scalar) - self[idx_1, :] = self[idx_1, :] - scalar * self[idx_2, :] # type: ignore - self.simplify(suppress_warnings=True) - - if verbosity >= 1: - display( - f"R_{idx_1 + 1} - \\left({sym.latex(scalar)}\\right)R_{idx_2 + 1} \\rightarrow R_{idx_1 + 1}", - opt="math", - ) - if verbosity >= 2: - display(self) - print("\n") - - return self - - def get_pivot_row( - self, col_idx: int, row_start_idx: int, follow_GE: bool = False - ) -> int | None: - """Finds the row index of the pivot element in a given column. - - This method attempts to find a row that contains a non-zero element in the - specified column. If the `follow_GE` flag is `False`, it first looks for - a non-zero constant that does not contain any symbolic expressions. If no - such element is found, it will return the first non-zero element. If the - entire column contains only zeros, the method returns -1. - - Args: - col_idx (int): The index of the column to search for the pivot. - row_start_idx (int): The row index to start searching from. - follow_GE (bool, optional): Flag to control whether to follow Gaussian elimination strategy. - - - `True`: Always return the first non-zero element, even if it is symbolic. - - `False`: First look for non-zero constants that are not symbolic expressions. - - Returns: - (int): The index of the row containing the pivot element, or None if no pivot is found. - - Examples: - >>> mat = Matrix([[1, 2, 3], [4, 5, 6], [0, 0, 0]]) - >>> mat.get_pivot_row(0, 0) - 0 - """ - - # Step 1: Search for a non-zero constant that is not symbolic (if not following Gaussian elimination) - # that it is easier to reduce other rows - if not follow_GE: - for row_idx in range(row_start_idx, self.rows): - term = self[row_idx, col_idx] - if term != 0: - # Check if it's not a symbolic expression - if not isinstance(term, Expr): - return row_idx - # Check if it's a non-symbolic constant - elif len(term.free_symbols) == 0: - return row_idx - - # Step 2: If no non-zero constant is found, return the first non-zero element (symbolic or not) - for row_idx in range(row_start_idx, self.rows): - term = self[row_idx, col_idx] - if term != 0: - return row_idx - - # Step 3: If no non-zero element is found, return None (indicating no pivot) - return None - - def get_pivot_pos(self) -> list[tuple[int, int]]: - """Finds the positions of the pivot elements in the matrix. - - This method checks the matrix to determine the positions of the pivots - (the first non-zero entry in each row) by examining each column one-by-one. - It assumes that the matrix is in Row Echelon Form (REF), as checked by the - [`is_echelon`][sympy.matrices.matrixbase.MatrixBase.is_echelon] property. - - It uses [`get_pivot_row`][..] to find the pivot row for each column. - For each pivot found, a tuple (row, column) is added to the result list. - - Returns: - (list[tuple[int, int]]): A list of lists, where each sublist contains a - tuple representing the position (row, column) of a pivot. - - Examples: - >>> mat = Matrix([[1, 2, 3], [0, 0, 5], [0, 0, 0]]) - >>> mat.get_pivot_pos() - [(0, 0), (1, 2)] - """ - - assert self.is_echelon # check for REF - - pivot_pos: list[tuple[int, int]] = [] - cur_row_pos = 0 - for cur_col_pos in range(self.cols): - pivot_row = self.get_pivot_row(cur_col_pos, cur_row_pos, follow_GE=False) - - if pivot_row is not None: - pivot_pos.append((pivot_row, cur_col_pos)) - cur_row_pos += 1 - - return pivot_pos - - def get_pivot_elements(self) -> list[sym.Expr]: - """Retrieves the pivot elements from the matrix. - - This method identifies the pivot positions (row, column) using the - [`get_pivot_pos`][..] method and - then extracts the elements at those positions in the matrix. - - Returns: - (list[Expr]): A list of pivot elements corresponding - to the positions identified by [`get_pivot_pos`][..]. - - Examples: - >>> mat = Matrix([[1, 2, 3], [0, 0, 5], [0, 0, 0]]) - >>> mat.get_pivot_elements() - [1, 5] - """ - - pivot_elements: list[sym.Expr] = [] - - for i, j in self.get_pivot_pos(): - pivot_elements.append(self[i, j]) # type: ignore - - return pivot_elements - - def _symbolic_reduce_row( - self, - row_idx: int, - pivot_row_idx: int, - col_idx: int, - max_tries: int = 2, - verbosity: int = 0, - L_matrix: Matrix | None = None, - ) -> None: - """Reduce a row using a pivot row, handling symbolic denominators. - - Used by both [`ref`][..] and [`rref_cases`][..]. If `L_matrix` is provided, - it records the operations to maintain the `L` matrix in PLU decomposition. - - Args: - row_idx (int): The index of the row to reduce. - pivot_row_idx (int): The index of the pivot row to use for reduction. - col_idx (int): The index of the column to reduce. - max_tries (int, optional): Maximum number of tries to reduce a row in case of symbolic denominators. - verbosity (int, optional): The level of verbosity for output. - - 0: No output. - - 1: Print the row reduction operation. - - 2: Print the row reduction operation and display the matrix. - L_matrix ([`Matrix`][...], optional): If provided, updates this matrix to reflect the row operations - for maintaining the L matrix in PLU decomposition. - """ - Identity = self.elem() - tries = 0 - while sym.simplify(self[row_idx, col_idx]) != 0: - tries += 1 - if tries > max_tries: - warn( - f"ERROR: Max tries exceeded to reduce row {row_idx + 1} with row {pivot_row_idx + 1}", - RuntimeWarning, - stacklevel=2, - ) - break - try: - scalar = self[row_idx, col_idx] / self[pivot_row_idx, col_idx] # type: ignore - scalar = scalar.expand().simplify() - - try: - decomp = sym.apart(scalar) # partial fractions - except Exception: - decomp = scalar - if isinstance(decomp, sym.Add): - terms = decomp.args - else: - terms = [decomp] - - for term in terms: - _, d = sym.fraction(term) - if sym.simplify(d) != 0: - self.reduce_row( - row_idx, term, pivot_row_idx, verbosity=verbosity - ) - if L_matrix is not None: - elem = Identity.copy().reduce_row( - row_idx, -term, pivot_row_idx, verbosity=0 - ) - L_matrix.__dict__.update((L_matrix @ elem).__dict__) - - # Scaling fallback for symbolic denominators - if sym.simplify(self[row_idx, col_idx]) != 0: - scalar = self[pivot_row_idx, col_idx] / self[row_idx, col_idx] # type: ignore - tmp = scalar.simplify() - if tmp is not None: - scalar = tmp - n, d = sym.fraction(scalar) - if (sym.simplify(n) != 0) and (sym.simplify(d) != 0): - self.scale_row(row_idx, scalar, verbosity=verbosity) - if L_matrix is not None: - elem = Identity.copy().scale_row( - row_idx, sym.Integer(1) / scalar, verbosity=0 - ) - L_matrix.__dict__.update((L_matrix @ elem).__dict__) - except Exception as error: - warn(f"Reduction error: {error}", RuntimeWarning) - break - - def ref( - self, - verbosity: int = 2, - max_tries: int = 2, - follow_GE: bool = False, - ) -> PLU: - """Find the Row Echelon Form (REF) of the matrix. - - This method applies Gaussian elimination (or a similar approach) to bring - the matrix to row echelon form. - - Note: - - PLU decomposition is the generalisation of the LU decomposition. Unlike - LU decomposition, PLU works for any matrix. - - The REF is obtained from `PLU.U` and the matrix is LU factorisable if `PLU.P` - is the identity matrix. - - Args: - verbosity (int, optional): Level of verbosity for the output. - - - 0: No output. - - 1: Output basic information (e.g., row operations). - - 2: Output detailed information (e.g., matrix states after each operation). - max_tries (int, optional): Maximum number of tries to reduce a row in case of symbolic denominators. - follow_GE (bool, optional): Whether to strictly follow Gaussian elimination rules. - - - `True`: Always return the first non-zero element, even if it is symbolic. - - `False`: First look for non-zero constants that are not symbolic expressions. - - Returns: - (PLU): A dataclass containing the following matrices: - - - `P` ([`Matrix`][...]): The permutation matrix used during the transformation. - - `L` ([`Matrix`][...]): The lower triangular matrix representing the multipliers used in the elimination process. - - `U` ([`Matrix`][...]): The upper triangular matrix in row echelon form. - - Examples: - >>> mat = Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) - >>> mat.ref(verbosity=0) - PLU(P=Matrix([ - [1, 0, 0] - [0, 1, 0] - [0, 0, 1] - ]), L=Matrix([ - [1, 0, 0] - [4, 1, 0] - [7, 2, 1] - ]), U=Matrix([ - [1, 2, 3] - [0, -3, -6] - [0, 0, 0] - ])) - - See Also: - - [`Matrix.LUdecomposition`][sympy.matrices.matrixbase.MatrixBase.LUdecomposition] - """ - - U = self.copy() - - Identity = self.elem() - L = self.elem() - P = self.elem() - - # Loop over each column - cur_row_pos = 0 - - for cur_col_pos in range(self.cols): - # Find the first non-zero row in the current column - pivot_row = U.get_pivot_row(cur_col_pos, cur_row_pos, follow_GE) - - if pivot_row is None: - # If no non-zero pivot is found, continue to the next column - continue - - # Swap the current row with the pivot row if necessary - if pivot_row != cur_row_pos: - U.swap_row(cur_row_pos, pivot_row, verbosity=verbosity) - P_elem = Identity.copy().swap_row(cur_row_pos, pivot_row, verbosity=0) - P = P @ P_elem - L = P_elem @ L @ P_elem - - # Eliminate the current column in rest of the rows below - for row_idx in range(cur_row_pos + 1, self.rows): - U._symbolic_reduce_row( - row_idx, cur_row_pos, cur_col_pos, max_tries, verbosity, L - ) - - cur_row_pos += 1 - - # Return the appropriate number of matrices based on the `matrices` parameter - return PLU(P, L, U) - - @staticmethod - def _case_item_key(item: tuple) -> tuple[str, str]: - """Return a canonical, orderable key for a symbolic (key, value) pair.""" - k, v = item - return (sym.srepr(sym.sympify(k)), sym.srepr(sym.sympify(v))) - - @classmethod - def _case_outcome_key(cls, case: RREFCase, rhs: Matrix | None) -> tuple[str, int]: - """Group key used by evaluate_cases for merge-by-outcome.""" - if rhs is None: - return ("hom", case.free_params) - if case.is_consistent is False: - return ("non", -1) - if case.free_params == 0: - return ("unique", 0) - return ("param", case.free_params) - - @classmethod - def _case_sort_key( - cls, case: RREFCase, rhs: Matrix | None - ) -> tuple[int, int, int, tuple]: - """Presentation order key for merged evaluate_cases output.""" - kind, param = cls._case_outcome_key(case, rhs) - priority = 2 - if kind == "non": - priority = 0 - elif kind == "unique": - priority = 1 - return ( - priority, - max(param, 0), - len(case.conditions), - tuple(sorted(cls._case_item_key(it) for it in case.conditions.items())), - ) - - @classmethod - def _dedupe_key(cls, case: RREFCase) -> tuple: - """Exact signature used for evaluate_cases de-duplication.""" - excluded_keys = tuple( - sorted(cls._case_item_key(tuple(d.items())[0]) for d in case.excluded) - ) - condition_keys = tuple( - sorted(cls._case_item_key(it) for it in case.conditions.items()) - ) - return ( - condition_keys, - excluded_keys, - case.is_consistent, - case.free_params, - case.pivots, - ) - - @classmethod - def _merge_case_group(cls, group_cases: list[RREFCase]) -> list[RREFCase]: - """Apply group-local absorption, redundancy removal, and dedupe.""" - # If a general case excludes a single assignment that is also - # represented by a specific sibling with the same outcome, absorb - # that assignment into the general case by removing the exclusion. - changed = True - while changed: - changed = False - for general in group_cases: - gen_items = set(general.conditions.items()) - gen_excluded = {tuple(d.items())[0] for d in general.excluded} - for specific in group_cases: - if general is specific: - continue - spec_items = set(specific.conditions.items()) - if not gen_items.issubset(spec_items): - continue - extra_items = spec_items - gen_items - if len(extra_items) != 1: - continue - extra_item = next(iter(extra_items)) - if extra_item not in gen_excluded: - continue - - general.excluded = [ - d for d in general.excluded if tuple(d.items())[0] != extra_item - ] - changed = True - - # Conservative merge: drop a specific case only if there is a less- - # specific case with the same outcome that already covers the - # dropped assignment(s). - kept: list[RREFCase] = [] - for candidate in group_cases: - cand_items = set(candidate.conditions.items()) - is_redundant = False - for general in group_cases: - if general is candidate: - continue - gen_items = set(general.conditions.items()) - if not gen_items.issubset(cand_items): - continue - dropped_items = cand_items - gen_items - if not dropped_items: - continue - - general_excluded = {tuple(d.items())[0] for d in general.excluded} - if all(item not in general_excluded for item in dropped_items): - is_redundant = True - break - - if not is_redundant: - kept.append(candidate) - - # Remove exact duplicates (same condition/outcome) while preserving order. - seen: set[tuple] = set() - deduped: list[RREFCase] = [] - for case in sorted( - kept, - key=lambda c: ( - len(c.conditions), - tuple(sorted(cls._case_item_key(it) for it in c.conditions.items())), - tuple( - sorted(cls._case_item_key(tuple(d.items())[0]) for d in c.excluded) - ), - ), - ): - key = cls._dedupe_key(case) - if key in seen: - continue - seen.add(key) - deduped.append(case) - - return deduped - - def evaluate_cases( - self, rhs: Matrix | None = None, verbosity: int = 0 - ) -> list[RREFCase]: - """Evaluates and displays all possible cases for solutions to a linear system involving the matrix. - - This method uses [`rref_cases`][..] to find symbolic cases, and group them - based on the system's outcome (no solution, unique solution, infinitely many solutions). - - 1. Merge redundant cases with identical outcomes only when a less- - specific case explicitly excludes the dropped assignment(s). - 2. Order results by outcome priority: - - No solution - - Unique solution - - Infinitely many solutions (increasing free parameters) - - Args: - rhs (Matrix, optional): The right-hand side of the system Ax = rhs. - If not provided, the system is treated as homogeneous. - verbosity (int, optional): The level of verbosity for the computation. - - Returns: - (list[RREFCase]): The merged and ordered list of symbolic RREF cases found by - [`rref_cases`][..]. - - Examples: - >>> a = sym.Symbol('a') - >>> A = Matrix([[a, 1], [0, 1]]) - >>> b = Matrix([[2], [3]]) - >>> cases = A.evaluate_cases(rhs=b, verbosity=0) - Summary of merged cases for non-homogeneous system: - Case 1: assume {a: 0}, excluding [] - No solution - Case 2: assume {}, excluding [{a: 0}] - Unique solution - - See Also: - - [`rref_cases`][..]: Returns case data without printing a summary or merging similar cases. - """ - cases = self.rref_cases(rhs=rhs, verbosity=verbosity) - - grouped: dict[tuple[str, int], list[RREFCase]] = defaultdict(list) - for case in cases: - grouped[self._case_outcome_key(case, rhs)].append(case) - - merged_cases: list[RREFCase] = [] - for _, group_cases in grouped.items(): - merged_cases.extend(self._merge_case_group(group_cases)) - - ordered_cases = sorted(merged_cases, key=lambda c: self._case_sort_key(c, rhs)) - - print( - f"Summary of merged cases for {'homogeneous' if rhs is None else 'non-homogeneous'} system:" - ) - for i, c in enumerate(ordered_cases, 1): - print(f"Case {i}: assume {c.conditions}, excluding {c.excluded}") - if rhs is None: - print(f"Homogeneous system with {c.free_params} free parameter(s)") - else: - if c.is_consistent: - if c.free_params == 0: - print("Unique solution") - else: - print(f"Solution with {c.free_params} free parameter(s)") - else: - print("No solution") - - if verbosity >= 1: - display(RREF(c.rref, c.pivots)) - print("\n") - - return ordered_cases - - # Override - def rref(self, *args, pivots: bool = True, **kwargs) -> RREF | Matrix: - """Computes the Reduced Row Echelon Form (RREF) of the matrix. - - This method is a wrapper for [`rref`][sympy.matrices.matrixbase.MatrixBase.rref] method - and returns the matrix in Reduced Row Echelon Form (RREF) along with the pivot positions. - - Args: - pivots (bool, optional): - If `True`, returns a tuple containing the RREF matrix and a list of pivot columns - *args: Positional arguments passed to SymPy's [`rref`][sympy.matrices.matrixbase.MatrixBase.rref] method. - **kwargs: Keyword arguments passed to SymPy's [`rref`][sympy.matrices.matrixbase.MatrixBase.rref] method. - - Returns: - (RREF): A dataclass containing the following: - - - `rref` ([`Matrix`][...]): The matrix in Reduced Row Echelon Form. - - `pivots` ([`list`][list][[`int`][int]]): A list of integers representing the indices of the pivot columns. - (Matrix): If `pivots` is `False`, only the `rref` will be returned. - - Examples: - >>> mat = Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) - >>> mat.rref() - RREF(rref=Matrix([ - [1, 0, -1] - [0, 1, 2] - [0, 0, 0] - ]), pivots=(0, 1)) - """ - if pivots: - rref_mat, pivot_pos = super().rref(*args, **kwargs) - else: - rref_mat = super().rref(*args, pivots=False, **kwargs) - - aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() - rref_mat = Matrix(rref_mat, aug_pos=aug) - - if pivots: - return RREF(rref_mat, pivot_pos) - else: - return rref_mat - - def _get_rref_pivots(self) -> list[int]: - """Return pivot column indices by scanning each row of an RREF matrix. - - For each row we take the leftmost non-zero entry as the pivot. Rows that - are entirely zero (zero rows) contribute no pivot. - """ - pivots: list[int] = [] - for row in range(self.rows): - for col in range(self.cols): - entry = sym.simplify(self[row, col]) - if entry != 0: - pivots.append(col) - break - return pivots - - def _check_rref_consistency(self) -> bool: - """Return `True` if the augmented RREF matrix represents a consistent system. - - A system is inconsistent when any row has all-zero entries on the left-hand side (LHS) of the - augmentation line(s) but a non-zero entry on the right-hand side (RHS). - """ - aug_pos = getattr(self, "_aug_pos", set()) - if not aug_pos: - raise ValueError( - "Matrix must have augmentation lines defined for consistency check." - ) - # Pick the leftmost augmentation line as the boundary between LHS and RHS - n_var_cols = min(aug_pos) + 1 - for row in range(self.rows): - lhs_zero = all( - sym.simplify(self[row, col]) == 0 for col in range(n_var_cols) - ) - rhs_nonzero = any( - sym.simplify(self[row, col]) != 0 - for col in range(n_var_cols, self.cols) - ) - if lhs_zero and rhs_nonzero: - return False - return True - - def _pivot_and_continue( - self, - conditions: dict, - cur_row: int, - cur_col: int, - pivot_row: int, - nonzero_assumptions: tuple[sym.Expr, ...] = (), - verbosity: int = 0, - ) -> "list[tuple[Matrix, dict]]": - """Swap the pivot into place, normalise the pivot row to 1, eliminate the - pivot column in every other row (full RREF), then recurse. - """ - # Create a copy to avoid mutating shared state across branches. - m = self.copy() - - # swap pivot row into position - if pivot_row != cur_row: - m.swap_row(cur_row, pivot_row, verbosity=verbosity) - - # normalise pivot to 1 (if not already 1) - pivot_val = sym.simplify(m[cur_row, cur_col]) - if pivot_val != 1: - m.scale_row(cur_row, sym.Integer(1) / pivot_val, verbosity=verbosity) - - # eliminate pivot column in all other rows (full RREF) - for row_idx in range(m.rows): - if row_idx == cur_row: - continue - m._symbolic_reduce_row(row_idx, cur_row, cur_col, verbosity=verbosity) - - return m._symbolic_rref( - conditions, - cur_row + 1, - cur_col + 1, - nonzero_assumptions=nonzero_assumptions, - verbosity=verbosity, - ) - - def _symbolic_rref( - self, - conditions: dict, - cur_row: int, - cur_col: int, - nonzero_assumptions: tuple[sym.Expr, ...] = (), - verbosity: int = 0, - ) -> "list[tuple[Matrix, dict]]": - """Recursively compute RREF, branching whenever a pivot entry has free - symbols that could be zero under some assignment. - """ - - # Apply current conditions and simplify - mat_eval = self.subs(conditions) - if not isinstance(mat_eval, Matrix): - mat_eval = Matrix(mat_eval) - mat_eval.simplify(rational=False, simplify=True, suppress_warnings=True) - - # Propagate prior "pivot != 0" assumptions under current conditions. - # If any such assumption simplifies to 0, this branch is contradictory. - next_nonzero: list[sym.Expr] = [] - for expr in nonzero_assumptions: - expr_eval = sym.simplify(expr.subs(conditions)) - if expr_eval == 0: - return [] - if expr_eval.free_symbols: - next_nonzero.append(expr_eval) - nonzero_assumptions = tuple(next_nonzero) - - # Base case: all columns (or rows) exhausted - if cur_col >= mat_eval.cols or cur_row >= mat_eval.rows: - if verbosity >= 1: - print("Completed branch with conditions:") - display(conditions) - if verbosity >= 2: - display(mat_eval) - print("\n") - return [(mat_eval, conditions)] - - # Find first non-zero entry in cur_col at or below cur_row - # Prefer constants to symbols (following GE logic) to avoid redundant branches - pivot_row = mat_eval.get_pivot_row(cur_col, cur_row, follow_GE=False) - - if pivot_row is None: - # No pivot in this column — skip to the next column. - return mat_eval._symbolic_rref( - conditions=conditions, - cur_row=cur_row, - cur_col=cur_col + 1, - nonzero_assumptions=nonzero_assumptions, - verbosity=verbosity, - ) - - pivot_entry = sym.simplify(mat_eval[pivot_row, cur_col]) - free_syms = pivot_entry.free_symbols - - if not free_syms: - # Pivot is a non-zero constant — proceed unconditionally. - return mat_eval._pivot_and_continue( - conditions=conditions, - cur_row=cur_row, - cur_col=cur_col, - pivot_row=pivot_row, - nonzero_assumptions=nonzero_assumptions, - verbosity=verbosity, - ) - - # Pivot has free symbols: check whether it can equal zero - try: - zero_solutions: list[dict] = sym.solve( - pivot_entry, list(free_syms), dict=True - ) - except Exception: - zero_solutions = [] - - if not zero_solutions: - # Cannot be zero — proceed unconditionally. - return mat_eval._pivot_and_continue( - conditions=conditions, - cur_row=cur_row, - cur_col=cur_col, - pivot_row=pivot_row, - nonzero_assumptions=nonzero_assumptions, - verbosity=verbosity, - ) - - # Branch: one branch per zero solution, plus the "non-zero" branch - results: list[tuple[Matrix, dict]] = [] - - if verbosity >= 1: - print( - f"Branching on pivot at ({pivot_row}, {cur_col}) due to possibility of zero value with free symbols {free_syms}" - ) - - branch_count = 1 - for zero_sol in zero_solutions: - # Skip degenerate solutions where no symbol is actually constrained. - if not zero_sol: - continue - - new_conds = {**conditions, **zero_sol} - - # Check if this branch contradicts a previous non-zero assumption - # by checking if substituting the zero solution produces division by zero. - test_mat = mat_eval.subs(zero_sol) - if test_mat.has(sym.zoo, sym.nan): - continue - - # Contradiction check with prior assumptions of the form expr != 0. - if any( - sym.simplify(expr.subs(zero_sol)) == 0 for expr in nonzero_assumptions - ): - continue - - # With this substitution the pivot becomes 0 — re-enter the same - # (cur_row, cur_col) so the algorithm searches for a different pivot. - if verbosity >= 1: - print( - f"Branch {branch_count} on pivot ({pivot_row}, {cur_col}) = 0 with conditions:" - ) - display(new_conds) - branch_count += 1 - results.extend( - mat_eval._symbolic_rref( - conditions=new_conds, - cur_row=cur_row, - cur_col=cur_col, - nonzero_assumptions=nonzero_assumptions, - verbosity=verbosity, - ) - ) - - # "Non-zero" branch: proceed with the current pivot as-is (symbolic but ≠ 0). - if verbosity >= 1: - print( - f"Branch {branch_count} on pivot ({pivot_row}, {cur_col}) ≠ 0 with conditions:" - ) - display(conditions) - results.extend( - mat_eval._pivot_and_continue( - conditions=conditions, - cur_row=cur_row, - cur_col=cur_col, - pivot_row=pivot_row, - nonzero_assumptions=nonzero_assumptions + (pivot_entry,), - verbosity=verbosity, - ) - ) - - return results - - def rref_cases( - self, rhs: Matrix | None = None, verbosity: int = 0 - ) -> list[RREFCase]: - """Compute all symbolic-RREF cases by splitting on zero-pivot conditions. - - When the matrix contains free symbols, different assignments of those - symbols can lead to structurally different row-echelon forms (different - numbers of pivots, inconsistencies, etc.). This method detects exactly - those critical values by branching at every pivot that could be zero, - and returns one [`RREFCase`][(p).RREFCase] per distinct branch. - - Algorithm: - - 1. Work column-by-column to find the leftmost pivot in each active row. - 2. If the candidate pivot entry has free symbols that can equal zero, - create two branches: - - **Zero branch**: substitute the zero-making values and retry the - same column (a different row may now become the pivot). - - **Non-zero branch**: treat the entry as a non-zero (possibly - symbolic) scalar, normalise the pivot row to 1, and eliminate - the pivot column in all other rows (full RREF). - 3. Recursion terminates when all columns (or rows) have been processed. - - Args: - rhs (Matrix, optional): Right-hand side of the system ``Ax = rhs``, - appended as an augmented column block. When provided, each - [`RREFCase`][(p).RREFCase] reports consistency in - `RREFCase.is_consistent`. - verbosity (int, optional): Level of verbosity for the computation. - - Returns: - (list[RREFCase]): One entry per distinct case. Each - [`RREFCase`][(p).RREFCase] contains: - - - ``conditions`` — the symbol substitutions that define the case. - - ``excluded`` — zero-conditions from *other* cases (i.e. what - is **not** assumed here), excluding redundant alternatives for - symbols already fixed by ``conditions``. - - ``rref`` — the RREF matrix (augmented if *rhs* was given). - - ``pivots`` — pivot column indices. - - ``free_params`` — number of free parameters. - - ``is_consistent`` — ``True``/``False`` (``None`` if no *rhs*). - - Examples: - Pure homogeneous system with one parameter: - - >>> import sympy as sym - >>> a = sym.Symbol('a') - >>> A = Matrix([[a, 1], [0, 1]]) - >>> cases = A.rref_cases() - >>> for c in cases: - ... print(c.conditions, '|', c.free_params, 'free params') - {a: 0} | 1 free params - {} | 0 free params - - Non-homogeneous system also checks consistency: - - >>> b_vec = Matrix([[2], [3]]) - >>> cases = A.rref_cases(rhs=b_vec) - >>> for c in cases: - ... print(c.conditions, '| consistent:', c.is_consistent) - {a: 0} | consistent: False - {} | consistent: True - - See Also: - - [`rref`][..] for the standard (non-branching) RREF wrapper. - - [`evaluate_cases`][..] for a printing-oriented case analysis. - """ - # Build working matrix (augmented when rhs is supplied). - if rhs is not None: - working_mat: Matrix = self.row_join(rhs, aug_line=True) - else: - working_mat = self.copy() - - n_var_cols = self.cols - # Run the recursive symbolic RREF, collecting all branches. - raw_results: list[tuple[Matrix, dict]] = working_mat._symbolic_rref( - conditions={}, cur_row=0, cur_col=0, verbosity=verbosity - ) - - # Collect all zero-conditions discovered across every branch so we can - # compute the "excluded" set for each case. - all_zero_conds: set[tuple] = set() - for _, conds in raw_results: - for item in conds.items(): - all_zero_conds.add(item) - - result_cases: list[RREFCase] = [] - for mat, conds in raw_results: - # excluded = zero-conditions present in other branches but not here. - # If this branch already fixes a symbol (e.g. a = 0), skip other - # alternatives for that same symbol (e.g. a = 1), because they are - # mutually exclusive and therefore redundant to list. - excluded_set = { - item - for item in (all_zero_conds - set(conds.items())) - if item[0] not in conds - } - excluded = [dict([item]) for item in sym.ordered(excluded_set)] - - # Pivot columns (all columns, including any augmented ones). - pivots = mat._get_rref_pivots() - - # Free parameters = LHS columns without a pivot. - n_pivot_lhs = sum(1 for p in pivots if p < n_var_cols) - free_params = n_var_cols - n_pivot_lhs - - # Consistency (only meaningful when rhs was provided). - is_consistent: bool | None = None - if rhs is not None: - is_consistent = mat._check_rref_consistency() - - # Preserve augmentation line from the original matrix. - aug = ( - working_mat._aug_pos.copy() - if hasattr(working_mat, "_aug_pos") - else set() - ) - rref_mat = Matrix(mat, aug_pos=aug) - - result_cases.append( - RREFCase( - conditions=conds, - excluded=excluded, - rref=rref_mat, - pivots=tuple(pivots), - free_params=free_params, - is_consistent=is_consistent, - ) - ) - - return result_cases - - # Override - def solve(self, rhs: Matrix, verbosity: int = 0) -> list[Matrix]: - """Solves the linear system `Ax = rhs` for `x`. - - This method uses SymPy's [`solve`][sympy.solvers.solvers.solve] method to find a solution vector `x` such that `self @ x = rhs`. - A list of solution matrices is returned. - If no solution exists (e.g., the system is inconsistent), a [ValueError][] is raised. - - Args: - rhs (Matrix): The right-hand side matrix or vector in the equation `Ax = rhs`. - - verbosity (int, optional): Level of verbosity for displaying intermediate steps: - - 0: No output. - - 1: Display the augmented matrix before and after RREF. - - Returns: - (list[Matrix]): A list of the solution vectors or matrices `x` that satisfies `Ax = rhs`. - - Raises: - ValueError: If no solution is found for the linear system, an exception is raised. - - Examples: - >>> A = Matrix([[1, 2], [3, 4]]) - >>> b = Matrix([[5], [11]]) - >>> A.solve(b) - [Matrix([ - [1] - [2] - ])] - - See Also: - - [`sympy.solve`][sympy.solvers.solvers.solve]: For solving equations in general. - - [`rref`][..]: For finding the reduced row echelon form of the matrix. - - [`solve_least_squares`][..]: For solving least squares problems. - """ - # Use sympy's solve function directly - x = Matrix.create_unk_matrix(r=self.cols, c=1) - solution = sym.solve(self @ x - rhs, x.free_symbols, dict=True) - - if verbosity >= 1: - print("Before RREF:") - display(self.row_join(rhs, aug_line=True)) - print("\nAfter RREF:") - display(self.row_join(rhs, aug_line=True).rref()) - - if len(solution) == 0: - # If no solution is found (e.g., inconsistent system or empty list), raise an error - if verbosity == 0: - display(self.row_join(rhs, aug_line=True).rref()) - raise ValueError( - "No solution found for the linear system. The system may be inconsistent." - ) - else: - return [x.subs(sol) for sol in solution] - - ############################# - # CHAPTER 2: MATRIX ALGEBRA # - ############################# - - def inverse( - self, - option: Literal["left", "right", "both"] | None = None, - matrices: int = 1, - verbosity: int = 0, - ) -> Matrix | PartGen | None: - """Computes the left or right inverse of a matrix, depending on its rank and the specified option. - - The method checks whether the matrix has full row rank or full column rank and computes either: - - The **left inverse** (if the matrix has full column rank). - - The **right inverse** (if the matrix has full row rank). - - If neither option is provided, the method automatically determines which inverse to compute based on the matrix's rank. - - Args: - option (str, optional): Specifies which inverse to compute: - - - `'left'` for the left inverse (requires the matrix to have full column rank). - - `'right'` for the right inverse (requires the matrix to have full row rank). - - `'both'` for the inverse of a square matrix (works on both sides). - - matrices (int, optional): Specifies the number of matrices to return: - - - 1: Returns only the inverse matrix. - - 2: Returns the particular and general solutions of the inverse. - - verbosity (int, optional): Level of verbosity for displaying intermediate steps: - - - 0: No output. - - 1: Display matrices before and after RREF. - - Returns: - (Matrix): If `matrices = 1`, returns the inverse matrix. - (PartGen): If `matrices = 2`, returns a dataclass containing the particular and general solutions of the inverse. - - Raises: - ValueError: If no valid inverse (left or right or both) is found, an exception is raised. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.inverse() - Matrix([ - [ -2, 1] - [3/2, -1/2] - ]) - """ - - if option is None: - rank = self.rank() - if rank == self.cols: - if verbosity: - print("Left inverse found!") - option = "left" - if rank == self.rows: - if verbosity: - print("Right inverse found!") - option = "right" - if self.rows == self.cols: - # square matrix inverse works on both sides - option = "both" - else: - raise ValueError( - f"No inverse found! Rank: {rank}, Rows: {self.rows}, Columns: {self.cols}. Try pseudo-inverse: .pinv()" - ) - - if option == "both" and self.rows != self.cols: - raise ValueError( - "Cannot compute both left and right inverse for non-square matrices!" - ) - - if (option is not None) and (verbosity >= 1): - if option == "left": - aug = self.T.copy().row_join( - Matrix.eye(self.cols, aug_pos=range(self.cols)) - ) - print("Before RREF: [self^T | eye]") - display(aug) - print("\nAfter RREF:") - display(aug.rref()) - else: - aug = self.copy().row_join( - Matrix.eye(self.rows, aug_pos=range(self.rows)) - ) - print("Before RREF: [self | eye]") - display(aug) - print("\nAfter RREF:") - display(aug.rref()) - - if option is not None: - X = Matrix.create_unk_matrix(r=self.cols, c=self.rows, symbol="x") - if option == "left": - eqn = X @ self - Matrix.eye(self.cols) - else: - eqn = self @ X - Matrix.eye(self.rows) - - sol = sym.solve(eqn, X.free_symbols) - if isinstance(sol, list) and len(sol) > 0: - # Multiple sets of solutions found, picks the first 1 - X = X.subs(sol[0]) - elif isinstance(sol, dict): - X = X.subs(sol) - else: - raise ValueError( - f"No {option} inverse found! Try pseudo-inverse: .pinv()" - ) - - if matrices == 1: - return X - elif matrices == 2: - return X.sep_part_gen() - - def elem(self) -> Matrix: - """Returns the identity matrix with the same number of rows as the current matrix. - - This method creates an identity matrix to be used for elementary row operations, - i.e., `A = I A` where `I` is the identity matrix. - - Returns: - (Matrix): An identity matrix with the same number of rows as the current matrix. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4], [5, 6]]) - >>> mat.elem() - Matrix([ - [1, 0, 0] - [0, 1, 0] - [0, 0, 1] - ]) - """ - return Matrix.eye(self.rows) - - # override - def adjoint(self) -> Matrix: - """Computes the adjugate (classical adjoint) of the matrix. - - This method calculates the classical adjoint (also known as the [adjugate](https://en.wikipedia.org/wiki/Adjugate_matrix) - in literature) of the matrix. The adjoint of a matrix (as defined in MA1522 syllabus) is the transpose of its cofactor matrix. - - Note: - If you wish to compute the conjugate transpose of the matrix (SymPy's definition for adjoint), - use `self.H` directly or `super(symbolic.Matrix, self).adjoint()` to call the parent method. - - Returns: - (Matrix): The classical adjoint (or adjugate) matrix of the current matrix. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> import pytest - >>> with pytest.warns(DeprecationWarning, match="The classical adjoint"): - ... mat.adjoint() - Matrix([ - [ 4, -2], - [-3, 1]]) - - See Also: - - SymPy's [`Matrix.adjugate`][sympy.matrices.matrixbase.MatrixBase.adjugate] - - SymPy's [`Matrix.adjoint`][sympy.matrices.matrixbase.MatrixBase.adjoint] for conjugate transpose. - """ - warn( - """The classical adjoint of the matrix is computed rather than the conjugate transpose. - Please use self.adj() instead to remove ambiguity.""", - DeprecationWarning, - stacklevel=2, - ) - return self.adjugate() - - def adj( - self, - method: Literal["bareiss", "berkowitz", "bird", "laplace", "lu"] = "berkowitz", - ) -> Matrix: - """Alias for the [`adjoint`][..] method. - - It returns the classical adjoint (or [adjugate](https://en.wikipedia.org/wiki/Adjugate_matrix)) of the matrix. - - Args: - method (str, optional): Method to use to find the cofactors, can be "bareiss", "berkowitz", - "bird", "laplace" or "lu". - Returns: - (Matrix): The classical adjoint of the current matrix. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.adj() - Matrix([ - [ 4, -2], - [-3, 1]]) - - See Also: - - SymPy's [`adjugate`][sympy.matrices.matrixbase.MatrixBase.adjugate] - """ - return self.adjugate(method=method) - - # override - def cramer_solve( - self, rhs: Matrix, det_method: str = "laplace", verbosity: int = 2 - ) -> Matrix: - """Solves the linear system using Cramer's Rule. - - This method applies Cramer's Rule to solve the linear system represented by the matrix and the right-hand side vector. - It computes the determinant of the matrix and uses it to find the solution vector. - - Args: - rhs (Matrix): The right-hand side vector in the equation `Ax = rhs`. - det_method (str, optional): The method to use for computing the determinant. Options include: - - - `'laplace'`: Uses the Laplace expansion method. - - `'berkowitz'`: Uses the Berkowitz algorithm. - - `'bird'`: Uses the Bird's algorithm. - - `'bareiss'`: Uses the Bareiss algorithm. - - `'lu'`: Uses LU decomposition. - - verbosity (int, optional): Level of verbosity for displaying intermediate steps: - - - 0: No output. - - 1: Display basic information. - - 2: Display detailed information. - - Returns: - (Matrix): The solution vector `x` that satisfies `self @ x = rhs`. - - Raises: - sympy.matrices.exceptions.NonSquareMatrixError: If the matrix is not square. - sympy.matrices.exceptions.ShapeError: If the matrix and the right-hand side vector have incompatible dimensions. - ValueError: If the determinant is zero, indicating that the system has no unique solution. - - Examples: - >>> A = Matrix([[1, 2], [3, 4]]) - >>> b = Matrix([[5], [11]]) - >>> A.cramer_solve(b, verbosity=0) - Matrix([ - [1] - [2] - ]) - - See Also: - - [`solve`][..]: For solving linear systems using other methods. - - SymPy's [`Matrix.det`][sympy.matrices.matrixbase.MatrixBase.det] for computing the determinant. - - SymPy's [`Matrix.cramer_solve`][sympy.matrices.matrixbase.MatrixBase.cramer_solve] - """ - if self.rows != rhs.rows: - raise sym.ShapeError( - "The right-hand side vector must have the same number of rows as the matrix." - ) - if rhs.cols != 1: - raise sym.ShapeError( - "The right-hand side vector must be a column vector (1 column)." - ) - if self.rows != self.cols: - raise sym.NonSquareMatrixError( - "Cramer's Rule can only be applied to square matrices." - ) - det = self.det(method=det_method) - if det == 0: - raise ValueError("Determinant is zero, no unique solution exists.") - - entries = [] - for i in range(self.cols): - # Create a copy of the matrix and replace the i-th column with the rhs vector - modified_matrix = self.copy() - modified_matrix[:, i] = rhs[:, 0] - if verbosity >= 2: - print(f"Modified matrix for column {i + 1}:") - display(modified_matrix) - det_i = modified_matrix.det(method=det_method) / det - if verbosity >= 1: - display( - _textify("Determinant for column ") - + str(i + 1) - + ": " - + sym.latex(det_i), - opt="math", - ) - entries.append(det_i) - return Matrix(entries) - - def column_constraints(self, use_ref: bool = False, verbosity: int = 1) -> Matrix: - r"""Computes the column constraints for the matrix by appending a symbolic vector. - - This method creates a matrix where a random column vector $\begin{pmatrix} x_1 \\ \vdots \\ x_m \end{pmatrix}$ - is added to the matrix as an additional column. It then constructs a larger augmented matrix - and optionally computes its Row Echelon Form (REF) or Reduced Row Echelon Form (RREF). - - The method modifies the matrix to ensure that the unknown vector is not reduced in RREF, - and the constraints for the matrix columns are calculated accordingly. - - Args: - use_ref (bool, optional): Whether to use Row Echelon Form (REF) instead of Reduced Row Echelon Form (RREF). - If `False`, RREF will be used. - verbosity (int, optional): Verbosity level for displaying information. - - - 0: No output - - 1: Display all information - - Returns: - (Matrix): A new matrix containing the result after applying REF or RREF to the augmented matrix. - - Examples: - >>> mat = Matrix([[1, 2], [2, 4]]) # linearly dependent columns - >>> mat.column_constraints(verbosity=0) - Matrix([ - [1, 2 | x_2/2] - [0, 0 | x_1 - x_2/2] - ]) - - >>> mat = Matrix([[1, 2], [3, 4]]) # linearly independent columns - >>> mat.column_constraints(verbosity=0) - Matrix([ - [1, 0 | -2*x_1 + x_2] - [0, 1 | 3*x_1/2 - x_2/2] - ]) - """ - - # write a random vector as x_1, ..., x_m, given m rows - vector = Matrix.create_unk_matrix(self.rows, 1, "x") - - # insert hidden column vectors so that the unknown vector is not reduced in rref - hidden = self.elem() - - M = self.copy().row_join(hidden).row_join(vector) - if use_ref: - res = M.ref().U - else: - res = M.rref(pivots=False) - - visible_cols = (*range(self.cols), -1) - res_matrix = res.select_cols(*visible_cols).aug_line(-2) # type: ignore - - if verbosity: - print("Before RREF: [self | vec]") - display(M.select_cols(*visible_cols).aug_line(-2)) - print("After RREF") - display(res_matrix) - print( - "For the system to be consistent, the following constraints must be satisfied." - ) - for i in range(res_matrix.rows): - # check for zero row - if res_matrix[i, : self.cols].norm() == 0: # type: ignore - display(sym.Eq(res_matrix[i, -1], 0)) - return res_matrix - - ###################################### - # CHAPTER 3: EUCLIDEAN VECTOR SPACES # - ###################################### - - def normalized( - self, - iszerofunc: Callable[[Expr], int] | None = None, - factor: bool = False, - ) -> Matrix | ScalarFactor: - """Normalizes the column vectors of the matrix (scaling each vector to have a unit norm). - - Args: - iszerofunc (Callable[[Expr], int], optional): Function to determine if a value should be treated as zero - factor (bool, optional): If `True`, returns the [`ScalarFactor`][(p).] dataclass. - If `False`, only the normalized matrix is returned. - - Returns: - (ScalarFactor): A dataclass containing the normalized matrix and the diagonal matrix of norms if `factor=True`. - (Matrix): The normalized matrix if `factor=False`. - - Examples: - >>> mat = Matrix([[0, 3], [0, 4]]) - >>> mat.normalized() - Matrix([ - [0, 3/5] - [0, 4/5] - ]) - - >>> mat = Matrix([[3], [4]]) - >>> mat.normalized(factor=True) - ScalarFactor(diag=Matrix([[1/5] - ]), full=Matrix([ - [3] - [4] - ]), order='FD') - """ - - for i in range(self.cols): - col = self.col(i) - scalar = sym.sqrt(sum(x**2 for x in col)) # Manual norm calculation - if iszerofunc is None: - if scalar != 0: - self[:, i] /= scalar # type: ignore - else: - if iszerofunc(scalar) != 0: - self[:, i] /= scalar # type: ignore - - if factor: - return self.scalar_factor(column=True) - else: - return self - - def is_linearly_independent( - self, - colspace: bool = True, - verbosity: int = 0, - ) -> bool: - """Determines if the vectors in the matrix are linearly independent. - - This method checks whether the columns (or rows) of the matrix are linearly independent - by computing its reduced row echelon form (RREF) and comparing the number of pivot columns - to the number of columns (or rows). - - Args: - colspace (bool, optional): If `True`, checks linear independence of columns (column space). - If `False`, checks linear independence of rows (row space). - - verbosity (int, optional): Level of output during the RREF and check. - - - 0: No output (default). - - 1: Print summary of the RREF and check. - - 2: Print the matrix before and after RREF, and the check details. - - Returns: - (bool): `True` if the matrix's columns (or rows) are linearly independent, `False` otherwise. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.is_linearly_independent() - True - - >>> mat = Matrix([[1, 2], [2, 4]]) - >>> mat.is_linearly_independent() - False - """ - rref = self.rref(pivots=True) - assert isinstance(rref, RREF), "RREF should return a RREF dataclass" - rref_mat, pivots = rref.rref, rref.pivots - - if verbosity == 1: - print("rref(self)") - elif verbosity >= 2: - print("Before RREF: self") - display(self) - print("\nAfter RREF:") - display(rref_mat) - - if colspace: - if verbosity >= 1: - print( - f"Check if Number of columns ({self.cols}) == Number of pivot columns ({len(pivots)})" # type: ignore - ) - return self.cols == len(pivots) - else: - if verbosity >= 1: - print( - f"Check if Number of rows ({self.rows}) == Number of pivot columns ({len(pivots)})" # type: ignore - ) - return self.rows == len(pivots) - - def get_linearly_independent_vectors( - self, colspace: bool = True, verbosity: int = 1 - ) -> Matrix: - """Returns a matrix containing the linearly independent vectors from the column space or row space. - - This method computes the reduced row echelon form (RREF) of the matrix and selects the non-zero rows - as linearly independent vectors. The result is a matrix whose columns (or rows) are linearly independent. - - Args: - colspace (bool, optional): If `True`, returns linearly independent vectors from the column space. - If `False`, returns from the row space. - - verbosity (int, optional): Level of output verbosity. - - - 0: No output. - - 1: Print a summary of the RREF and selection. - - Returns: - (Matrix): A matrix whose columns (if colspace=True) or rows (if colspace=False) are linearly independent vectors. - - Examples: - >>> mat = Matrix([[1, 2], [2, 4]]) - >>> mat.get_linearly_independent_vectors(colspace=True, verbosity=0) - Matrix([ - [1] - [2] - ]) - """ - if colspace: - rref = self.rref(pivots=True) - assert isinstance(rref, RREF), "RREF should return a RREF dataclass" - if verbosity >= 1: - print("Before RREF: [self]") - display(self) - print("\nAfter RREF:") - display(rref) - print("Select columns of self corresponding to pivot positions.") - return self.select_cols(*rref.pivots) - else: - rref = self.T.rref(pivots=True) - assert isinstance(rref, RREF), "RREF should return a RREF dataclass" - if verbosity >= 1: - print("Before RREF: [self^T]") - display(self.T) - print("\nAfter RREF:") - display(rref) - print("Select rows of self corresponding to pivot positions.") - return self.select_rows(*rref.pivots) - - def simplify_basis(self, colspace: bool = True, verbosity: int = 2) -> Matrix: - """Returns a simplified basis for the column space or row space of the matrix. - - This method computes a basis for either the column space or the row space of the matrix - by reducing the matrix (or its transpose) to reduced row echelon form (RREF) and selecting - the nonzero rows as basis vectors. The result is a matrix whose columns (or rows) form a basis - for the specified subspace. - - Args: - colspace (bool, optional): If `True`, returns a basis for the column space. - If `False`, returns a basis for the row space. - - verbosity (int, optional): Level of output verbosity. - - - 0: No output. - - 1: Print a summary of the RREF and basis selection. - - 2: Print the matrix before and after RREF, and show the selected basis vectors. - - Returns: - (Matrix): A matrix whose columns (if colspace=True) or rows (if colspace=False) form a basis - for the corresponding subspace. - - Examples: - >>> mat = Matrix([[1, 2], [2, 4]]) - >>> mat.simplify_basis(colspace=True, verbosity=0) - Matrix([ - [1] - [2] - ]) - - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.simplify_basis(colspace=False, verbosity=0) - Matrix([ - [1, 0] - [0, 1] - ]) - """ - if colspace: - rref_mat = self.T.rref(pivots=False) - assert isinstance(rref_mat, Matrix), "RREF should return a Matrix" - if verbosity == 1: - print("Select non-zero rows of rref(self.T) as basis vectors.") - if verbosity >= 2: - print("Before RREF: self^T") - display(self.T) - print("\nAfter RREF:") - display(rref_mat) - else: - rref_mat = self.rref(pivots=False) - assert isinstance(rref_mat, Matrix), "RREF should return a Matrix" - if verbosity == 1: - print("Select non-zero rows of rref(self) as basis vectors.") - if verbosity >= 2: - print("Before RREF: self") - display(self) - print("\nAfter RREF:") - display(rref_mat) - - idxs = [] - for i in range(rref_mat.rows): - if any(not _is_zero(x) for x in rref_mat[i, :]): # type: ignore - idxs.append(i) - - mat = rref_mat.select_rows(*idxs) - if colspace: - return mat.T - else: - return mat - - def extend_basis( - self, span_subspace: Matrix | None = None, verbosity: int = 2 - ) -> Matrix: - r"""Extends the matrix to form a basis for the span of the given subspace. - - This method extends the column space of the current matrix to include the columns of the provided - `span_subspace`, computes the Reduced Row Echelon Form (RREF) of the augmented matrix, - and then selects the pivot columns to return the extended basis. - - If no `span_subspace` is provided, the identity matrix (i.e. $\mathrm{span} \left(\mathbb{R}^n \right)$) is used as the default. - The result is a matrix with the extended basis that spans the combined space of the `self` - matrix and the `span_subspace`. - - Args: - span_subspace (Matrix, optional): A matrix whose columns represent the subspace to - be added to the current matrix. If `None`, the identity matrix is used. - - verbosity (int, optional): Verbosity level for displaying information. - - - 0: No output. - - 1: Display steps. - - 2: Display the matrix before and after RREF. - - Returns: - (Matrix): A matrix whose column space represents the extended basis, consisting of the pivot columns - from the RREF of the augmented matrix. - - Examples: - >>> mat = Matrix([[1, 2], [2, 4]]) - >>> mat.extend_basis(verbosity=0) - Matrix([ - [1, 1] - [2, 0] - ]) - """ - - if span_subspace is None: - span_subspace = self.elem() - aug = self.copy().row_join(span_subspace) - rref = aug.rref(pivots=True) - assert isinstance(rref, RREF), "RREF should return a RREF dataclass" - - if verbosity == 1: - print("rref([self | span_subspace])") - elif verbosity >= 2: - print("Before RREF: [self | span_subspace]") - display(aug) - print("\nAfter RREF:") - display(rref) - print( - "Select columns of rref([self | span_subspace]) corresponding to pivot positions." - ) - - return aug.select_cols(*rref.pivots) - - def intersect_subspace(self, other: Matrix, verbosity: int = 2) -> Matrix: - """Computes the intersection of two subspaces by finding the nullspace of their orthogonal complements. - - This method computes the intersection of the subspaces spanned by the columns of the current matrix - (`self`) and the provided matrix (`other`). The intersection is computed by finding the union of the nullspace of - the row space of the two matrices, and then finding its orthogonal complement. - - Args: - other (Matrix): The second matrix representing the other subspace to intersect with the current matrix. - verbosity (int, optional): Level of verbosity for displaying intermediate steps: - - - 0: No output. - - 1: Display steps. - - 2: Display the relevant matrices. - Defaults to 2. - - Returns: - (Matrix): A matrix whose columns form a basis for the intersection of the two subspaces. - - Examples: - >>> mat1 = Matrix([[1, 0], [0, 1]]) - >>> mat2 = Matrix([[1, 1], [0, 0]]) - >>> mat1.intersect_subspace(mat2, verbosity=0) - Matrix([ - [1] - [0] - ]) - """ - - # Construct 2 matrices A and B, whose solution space (ie nullspace) is - # the subspace self and other respectively. Observe that the solution - # space is orthogonal to the row space, so it is the orthogonal complement. - - A = self.orthogonal_complement().T - B = other.orthogonal_complement().T - - # Now we obtain A and B which represent the linear system of 2 different - # subspaces. When we solve these simultaneously, we will find the solution - # space which contains vectors which are solutions to both linear systems. - aug = A.col_join(B) - if verbosity == 1: - print("A = Null(self^T)^T") - print("B = Null(other^T)^T") - print("Null([A ; B])") - - if verbosity >= 2: - print( - "A linear system whose solution space is the subspace of self. Null(self^T)^T" - ) - display(A) - print( - "\nA linear system whose solution space is the subspace of other. Null(other^T)^T" - ) - display(B) - print("\nBefore RREF: [self ; other]") - display(aug) - print("\nAfter RREF:") - display(aug.rref()) - - return Matrix.from_list(aug.nullspace()) - - def is_subspace_of(self, other: Matrix, verbosity: int = 2) -> bool: - r"""Checks if the current matrix is a subspace of another matrix. - - This method determines whether the subspace spanned by the columns of the current matrix (`self`) - is a subspace of the provided matrix (`other`). It does so by checking if the row-reduced echelon form - (RREF) of the augmented matrix `[other | self]`. - - Args: - other (Matrix, optional): The second matrix representing the other subspace to compare with the current matrix. - If `None`, the identity matrix is used to check if it spans $\mathbb{R}^\text{self.rows}}$ - verbosity (int, optional): Level of verbosity for displaying intermediate steps: - - - 0: No output. - - 1: Display the steps. - - 2: Display the relevant matrices. - - Returns: - (bool): `True` if the subspace spanned by `self` is a subspace of `other`, `False` otherwise. - - Raises: - sympy.matrices.exceptions.ShapeError: If the number of rows in the current matrix and the target matrix are different. - - Examples: - >>> mat1 = Matrix([[1, 0], [0, 1]]) - >>> mat2 = Matrix([[1], [0]]) - >>> mat2.is_subspace_of(mat1, verbosity=0) - True - """ - if self.rows != other.rows: - raise sym.ShapeError( - f"The matrices have incompatible number of rows ({self.rows}, {other.rows})" - ) - - aug = other.copy().row_join(self) - sub = aug.rref(pivots=True) - assert isinstance(sub, RREF), "RREF should return a RREF dataclass" - if verbosity == 1: - print("Check rref([other | self])") - if verbosity >= 2: - print("Check if span(self) is subspace of span(other)") - print("\nBefore RREF: [other | self]") - display(aug) - print("\nAfter RREF:") - display(sub) - if max(sub.pivots) >= other.cols: - print("Span(self) is not a subspace of span(other).\n") - else: - print("Span(self) is a subspace of span(other).\n") - - return max(sub.pivots) < other.cols - - def is_same_subspace(self, other: Matrix | None = None, verbosity: int = 2) -> bool: - r"""Checks if two subspaces are the same by verifying if each subspace is a subspace of the other. - - This method determines whether the subspaces spanned by the columns of the current matrix (`self`) - and the provided matrix (`other`) are the same. It does so by calling the `is_subspace_of` method - twice: first to check if `self` is a subspace of `other`, and then to check if `other` is a subspace of `self`. - If both checks return `True`, then the subspaces are considered the same. - - Args: - other (Matrix, optional): The second matrix representing the other subspace to compare with the current matrix. - If `None`, the identity matrix is used to check if it spans $\mathbb{R}^\text{self.rows}}$ - verbosity (int, optional): Level of verbosity for displaying intermediate steps: - - - 0: No output. - - 1: Display the steps. - - 2: Display the relevant matrices. - Defaults to 2. - - Returns: - (bool): `True` if the subspaces spanned by `self` and `other` are the same, `False` otherwise. - - Raises: - sympy.matrices.exceptions.ShapeError: If the number of rows in the current matrix and the target matrix are different. - - Examples: - >>> mat1 = Matrix([[1, 0], [0, 1]]) - >>> mat2 = Matrix([[1, 2], [3, 4]]) - >>> mat1.is_same_subspace(mat2, verbosity=0) - True - """ - if other is None: - rref = self.rref(pivots=True) - assert isinstance(rref, RREF), "RREF should return a RREF dataclass" - if verbosity >= 1: - print("Check rref(self) does not have zero rows") - if verbosity >= 2: - print("Before RREF: self") - display(self) - print("\nAfter RREF:") - display(rref.rref) - return len(rref.pivots) == self.rows # no zero rows - - if self.rows != other.rows: - raise sym.ShapeError( - f"The matrices have incompatible number of rows ({self.rows}, {other.rows})" - ) - - if verbosity >= 1: - print("Check if span(self) is subspace of span(other), and vice versa.") - return self.is_subspace_of( - other=other, verbosity=verbosity - ) and other.is_subspace_of(other=self, verbosity=verbosity) - - def coords_relative(self, basis: Matrix, verbosity: int = 2) -> Matrix: - """Computes the coordinates of the current vector relative to a given basis. - - This method finds the coordinate vector `c` such that `self = basis @ c`, - where `basis` is a matrix whose columns form a basis, and `self` is a column vector. The method - achieves this by augmenting the target matrix with the current matrix, performing - Reduced Row Echelon Form (RREF), and extracting the appropriate part of the resulting matrix. - - Args: - basis (Matrix): The matrix whose columns form the target basis. - - verbosity (int, optional): Level of output verbosity. - - - 0: No output. - - 1: Print summary of the RREF and solution. - - 2: Print the matrix before and after RREF, and show the solution details. - - Returns: - (Matrix): The coordinate vector of `self` relative to the basis `to`. - - Raises: - sympy.matrices.exceptions.ShapeError: If `self` is not a column vector - or if the number of rows in `self` and `basis` do not match. - ValueError: If the system is inconsistent and no solution exists. - - Examples: - >>> v = Matrix([[3], [7]]) - >>> B = Matrix([[1, 2], [1, 3]]) - >>> v.coords_relative(B, verbosity=0) - Matrix([ - [-5] - [ 4] - ]) - """ - if self.cols != 1: - raise sym.ShapeError( - f"self should be a vector with 1 column. ({self.cols})" - ) - if self.rows != basis.rows: - raise sym.ShapeError( - f"The matrices have incompatible number of rows ({self.rows}, {basis.rows})" - ) - - M = basis.copy().row_join(self) - rref = M.rref(pivots=True) - assert isinstance(rref, RREF), "RREF should return a RREF dataclass" - rref_mat, pivots = rref.rref, rref.pivots - - if verbosity == 1: - print("Solve system via rref([to | self])") - elif verbosity >= 2: - print("Before RREF: [to | self]") - display(M) - print("\nAfter RREF:") - display(rref_mat) - - if basis.cols in pivots: - raise ValueError("No solution found due to inconsistent system.") - - sol = basis.solve(self)[0] # solution should be unique - assert isinstance(sol, Matrix), "Solution should be a Matrix (vector) object" - return sol - - def transition_matrix(self, to: Matrix, verbosity: int = 2) -> Matrix: - """Computes the transition matrix that transforms this matrix to another matrix. - - This method computes the transition matrix `P` such that `self = P @ to`, - where `to` is the target basis, and `self` is the current basis. The method - achieves this by augmenting the target matrix with the current matrix, performing - Reduced Row Echelon Form (RREF), and extracting the appropriate part of the resulting matrix. - - Args: - to (Matrix): The matrix to which the current matrix should be transformed. - - verbosity (int, optional): Verbosity level for displaying information. - - - 0: No output. - - 1: Display the steps. - - 2: Display the relevant matrices. - - Returns: - (Matrix): The transition matrix `P` that satisfies `self = P @ to`. - - Raises: - AssertionError: If the columns of the `self` matrix and `to` matrix do not span the same subspace. - - Examples: - >>> mat1 = Matrix([[1, 0], [0, 1]]) - >>> mat2 = Matrix([[2, 0], [0, 2]]) - >>> mat1.transition_matrix(to=mat2, verbosity=0) - Matrix([ - [1/2, 0], - [ 0, 1/2]]) - """ - assert self.is_same_subspace(to, verbosity=0), ( - "Column vectors of both matrices must span the same subspace." - ) - - M = to.copy().row_join(self) - res = M.rref(pivots=False) - assert isinstance(res, Matrix), "RREF should return a Matrix object" - if verbosity == 1: - print("rref([to | self])") - elif verbosity >= 2: - print("Before RREF: [to | self]") - display(M) - print("\nAfter RREF:") - display(res) - P = res[: to.cols, to.cols :] - return P # type: ignore - - ############################################### - # CHAPTER 4: SUBSPACES ASSOCIATED TO A MATRIX # - ############################################### - - # Override - def nullspace(self, verbosity: int = 0, *args, **kwargs) -> list[Matrix]: - """ - Computes the null space (kernel) of the matrix, i.e., the set of vectors that satisfy `self @ x = 0`. - - This method utilizes the rank-nullity theorem to determine if the null space exists. Fixes the - issue with SymPy [implementation][sympy.matrices.matrixbase.MatrixBase.nullspace] of nullspace where - it raises an exception if the nullspace is trivial (only contain the zero vector). - If the matrix has full column rank (i.e., rank = number of columns), it has no non-trivial null space, - and an empty list is returned. - - Args: - verbosity (int, optional): Level of verbosity for displaying intermediate steps. - - - 0: No output. - - 1: Display the matrix before and after row-reduction (RREF). - *args: Additional positional arguments passed to SymPy's [`nullspace`][sympy.matrices.matrixbase.MatrixBase.nullspace] method. - **kwargs: Additional keyword arguments passed to SymPy's [`nullspace`][sympy.matrices.matrixbase.MatrixBase.nullspace] method. - - Returns: - list[Matrix]: A list of [`Matrix`][...] objects representing the null space vectors. Returns an empty list if the null space is trivial. - - Examples: - >>> mat = Matrix([[1, 2], [3, 6]]) - >>> mat.nullspace() - [Matrix([ - [-2], - [ 1]])] - - See Also: - - SymPy's [`Matrix.nullspace`][sympy.matrices.matrixbase.MatrixBase.nullspace] - - [`orthogonal_complement`][..] for computing the orthogonal complement of the matrix. - """ - - # Issue with SymPy implementation of nullspace when there is None - # Using rank nullity theorem to verify there are vectors spanning nullspace - if verbosity >= 1: - print("Before RREF: [self]") - display(self) - print("\nAfter RREF:") - display(self.rref()) - - if self.rank() == self.cols: - if verbosity >= 1: - warn( - "Only trivial nullspace (0-vector) detected!", - UserWarning, - stacklevel=2, - ) - return [] - else: - return super().nullspace(*args, **kwargs) - - def nullity(self) -> int: - """Computes the nullity of the matrix, which is the dimension of its null space. - - The nullity is defined as the number of free variables in the solution to the homogeneous equation `self @ x = 0`. - It can be computed as `nullity = cols - rank`, where `cols` is the number of columns in the matrix and `rank` is its rank. - - Returns: - (int): The nullity of the matrix. - - Examples: - >>> mat = Matrix([[1, 2], [3, 6]]) - >>> mat.nullity() - 1 - """ - return self.cols - self.rank() - - ####################################################### - # CHAPTER 5: ORTHOGONALITY AND LEAST SQUARES SOLUTION # - ####################################################### - - def orthogonal_complement(self, verbosity: int = 0) -> Matrix: - """Computes the orthogonal complement of the matrix (the null space of its transpose). - - The orthogonal complement consists of all vectors that are orthogonal to the column space of the matrix. - This method computes the null space of the transpose of the matrix, which gives a basis for the orthogonal complement. - - Note: - The orthogonal complement is the set of all vectors `v` such that `self^T @ v = 0`. - - Args: - verbosity (int, optional): Level of verbosity for debugging. - - - 0: No output. - - 1: Display the matrix before and after row-reduction (RREF). - - - Returns: - (Matrix): A matrix whose columns form a basis for the orthogonal complement. - - Examples: - >>> mat = Matrix([[1, 0], [0, 0]]) - >>> mat.orthogonal_complement() - Matrix([ - [0] - [1] - ]) - """ - - return Matrix.from_list(self.transpose().nullspace(verbosity)) - - def is_vec_orthogonal(self, verbosity: int = 1) -> bool: - r"""Checks if the column vectors of the matrix are orthogonal to each other. - - This method computes `self^T @ self` and checks if the result is diagonal. - If the result is diagonal, the vectors are orthogonal (i.e., $u_i \dot u_j = 0 \forall i != j$). - - Note: - This method checks for orthogonality, not orthonormality. For orthonormality, use [`is_mat_orthogonal`][..]. - - Args: - verbosity (int, optional): Level of verbosity for displaying intermediate results. - - - 0: No output. - - 1: Display the matrix product `self.T @ self`. - - Returns: - (bool): `True` if the column vectors are orthogonal, `False` otherwise. - - Examples: - >>> mat = Matrix([[1, 0], [0, 2]]) - >>> mat.is_vec_orthogonal(verbosity=0) - True - """ - - res = self.T @ self - if verbosity >= 1: - print("Check if [self^T @ self] is a diagonal matrix") - display(res) - return res.is_diagonal() - - def is_mat_orthogonal(self, verbosity: int = 1) -> bool: - """ - Checks if the matrix is orthogonal (i.e., its columns are orthonormal). - - A matrix is orthogonal if its columns are orthonormal, i.e., if `self.T @ self` is the identity matrix. - This method computes `self.T @ self` and checks if the result is an identity matrix. - - Args: - verbosity (int, optional): Level of verbosity for displaying intermediate results. - - - 0: No output. - - 1: Display the matrix product `self.T @ self`. - - Returns: - (bool): True if the matrix is orthogonal, False otherwise. - - Examples: - >>> mat = Matrix([[1, 0], [0, 1]]) - >>> mat.is_mat_orthogonal(verbosity=0) - True - - >>> mat = Matrix([[1, 0], [0, 2]]) - >>> mat.is_mat_orthogonal(verbosity=0) - False - """ - - res = self.T @ self - if verbosity >= 1: - print("self^T @ self") - display(res) - return res.is_diagonal() and all(entry == 1 for entry in res.diagonal()) - - def orthogonal_decomposition(self, to: Matrix, verbosity: int = 0) -> VecDecomp: - """Decomposes the current vector (or matrix) into its orthogonal projection onto a subspace and its orthogonal complement. - - This method computes the orthogonal decomposition of `self` relative to the subspace spanned by the columns of `to`. - It finds the projection of `self` onto the subspace (`proj`) and the component orthogonal to the subspace (`norm`), such that: - `self = proj + norm` - - The projection is computed using the least squares solution. - - Args: - to (Matrix): The matrix whose columns form the subspace onto which to project `self`. - verbosity (int, optional): Level of verbosity for displaying intermediate results. - - - 0: No output. - - 1: Display the projected and normal components. - - 2: Display detailed steps. - - Returns: - (VecDecomp): A dataclass with fields: - - - proj ([`Matrix`][...]): The projection of `self` onto the subspace spanned by `to`. - - norm ([`Matrix`][...]): The component of `self` orthogonal to the subspace spanned by `to`. - - Examples: - >>> v = Matrix([[3], [4]]) - >>> B = Matrix([[1], [0]]) - >>> v.orthogonal_decomposition(B) - VecDecomp(proj=Matrix([ - [3], - [0]]), norm=Matrix([ - [0] - [4] - ])) - """ - - sol = to.solve_least_squares(self, verbosity=verbosity) - proj = to @ sol - norm = self - proj - - if verbosity >= 1: - print("Projected component: Au") - display(proj) - print("Normal component: b - b_proj") - display(norm) - - assert proj + norm == self - return VecDecomp(proj, norm) - - def proj_comp(self, to: Matrix, verbosity: int = 0) -> Matrix: - """Computes the orthogonal projection of the current vector (or matrix) onto the subspace spanned by the columns of another matrix. - - This method returns the component of `self` that lies in the subspace defined by the columns of `to`. - It is equivalent to the projection of `self` onto the subspace, as computed by the orthogonal decomposition. - - Args: - to (Matrix): The matrix whose columns form the subspace onto which to project `self`. - verbosity (int, optional): Level of verbosity for displaying intermediate results. - - - 0: No output. - - 1: Display the projected component. - - Returns: - (Matrix): The projection of `self` onto the subspace spanned by `to`. - - Examples: - >>> v = Matrix([[3], [4]]) - >>> B = Matrix([[1], [0]]) - >>> v.proj_comp(B) - Matrix([ - [3], - [0]]) - """ - - return self.orthogonal_decomposition(to=to, verbosity=verbosity).proj - - def norm_comp(self, to: Matrix, verbosity: int = 0) -> Matrix: - """Computes the component of the current vector (or matrix) orthogonal to the subspace spanned by the columns of another matrix. - - This method returns the part of `self` that is perpendicular to the subspace defined by the columns of `to`. - It is equivalent to the normal component from the orthogonal decomposition. - - Args: - to (Matrix): The matrix whose columns form the subspace to which the orthogonal component is computed. - verbosity (int, optional): Level of verbosity for displaying intermediate results. - - - 0: No output (default). - - 1: Display the normal component. - - Returns: - (Matrix): The component of `self` orthogonal to the subspace spanned by `to`. - - Examples: - >>> v = Matrix([[3], [4]]) - >>> B = Matrix([[1], [0]]) - >>> v.norm_comp(B) - Matrix([ - [0] - [4] - ]) - """ - - return self.orthogonal_decomposition(to=to, verbosity=verbosity).norm - - def gram_schmidt( - self, factor: bool = True, verbosity: int = 1 - ) -> Matrix | ScalarFactor: - """Performs Gram-Schmidt orthogonalization to convert a set of vectors (columns of the matrix) into - an orthogonal set (that includes 0 vectors if any). - - Args: - factor (bool): If `True`, the resulting orthogonal vectors will be scaled to have integer factors. - verbosity (int): Level of verbosity: - - - 0: No output. - - 1: Display intermediate results for each step of the process. - - Returns: - (Matrix): A matrix whose columns are the orthogonalized vectors. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.gram_schmidt(factor=False, verbosity=0) - Matrix([ - [ sqrt(10)/10, 3*sqrt(10)/10] - [3*sqrt(10)/10, -sqrt(10)/10] - ]) - """ - - if self.cols == 0: - return Matrix() - if verbosity >= 1: - display(f"v_{1} = {sym.latex(self.select_cols(0))}", opt="math") - - orthogonal_set = [self.select_cols(0)] - for i in range(1, self.cols): - u = self.select_cols(i) - u_orig = u.copy() - latex_eq = f"v_{i + 1} = {sym.latex(u)}" - for _, v in enumerate(orthogonal_set, start=1): - if v.norm() != 0: - latex_eq += f"- \\left(\\frac{{{sym.latex(u_orig.dot(v))}}}{{{sym.latex(v.dot(v))}}}\\right) {sym.latex(v)}" - u -= (u_orig.dot(v) / v.dot(v)) * v - - if verbosity >= 1: - disp_u = u.copy() - if factor: - scalar = sym.gcd(tuple(u)) # type: ignore - disp_u = sym.MatMul(scalar, u / scalar, evaluate=False) - latex_eq += f" = {sym.latex(disp_u)}" - display(latex_eq, opt="math") - - if u.norm() == 0 and (verbosity >= 1): - warn( - "Vectors are linearly dependent. Note that there is no QR factorisation", - UserWarning, - stacklevel=2, - ) - orthogonal_set.append(u) - - return Matrix.from_list(orthogonal_set).normalized(factor=factor) - - # Override - def QRdecomposition(self, full: bool = False, verbosity: int = 0) -> QR: - """Computes the QR decomposition of the matrix. Optionally computes the full QR decomposition. - - A full QR decomposition returns an **orthogonal (square) matrix** `Q` and an upper triangular matrix `R` - such that `self = Q @ R`. On the other hand, a reduced QR decomposition returns `Q` and `R` such that - `self = Q @ R`, where `Q` has **orthonormal columns**. - - Args: - full (bool): If `True`, computes the full QR decomposition. - verbosity (int, optional): Level of verbosity for displaying intermediate results: - - - 0: No output. - - 1: Display intermediate results for each step of the process. - - Returns: - (QR): A dataclass containing: - - - `Q` ([`Matrix`][...]): A matrix with orthonormal columns (or orthogonal matrix if full QR decomposition). - - `R` ([`Matrix`][...]): An upper triangular matrix. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.QRdecomposition() - QR(Q=Matrix([ - [ sqrt(10)/10, 3*sqrt(10)/10] - [3*sqrt(10)/10, -sqrt(10)/10] - ]), R=Matrix([ - [sqrt(10), 7*sqrt(10)/5] - [ 0, sqrt(10)/5] - ])) - - See Also: - - SymPy's [`Matrix.QRdecomposition`][sympy.matrices.matrixbase.MatrixBase.QRdecomposition] - - [`gram_schmidt`][..] for performing the Gram-Schmidt process to find an orthogonal basis. - """ - if verbosity >= 1: - print("Finding orthogonal basis via Gram-Schmidt process:") - Q = self.gram_schmidt(factor=False, verbosity=verbosity) - assert isinstance(Q, Matrix), "Result should be a Matrix object" - print("Q matrix:") - display(Q) - print("R matrix: Q.T @ self") - display(Q.T @ self) - - # Modified SymPy's implementation to compute full QR decomposition if required. - Q, R = super().QRdecomposition() - if full and Q.rows != Q.cols: - Q = Matrix(Q) - Q_aug = Q.row_join(Q.elem(), aug_line=False).QRdecomposition()[0] - R_aug = Matrix(R.col_join(sym.zeros(Q_aug.cols - R.rows, R.cols))) - assert Q_aug @ R_aug == self - return QR(Q_aug, R_aug) - return QR(Q, R) - - def solve_least_squares( - self, rhs: Matrix, verbosity: int = 1, matrices: int = 1, *args, **kwargs - ) -> Matrix | PartGen: - r"""Solves the least squares problem $\min || \mathrm{self} \, \mathbf{x} - \mathrm{rhs}||^2$. - - Uses SymPy's built-in method for least squares when the rank condition is met, otherwise uses a custom - solution approach using the normal equations: $\mathrm{self}^{\top} \mathrm{self} \, \mathbf{x} = \mathrm{self}^{\top} \mathrm{rhs}$ - - Args: - rhs (Matrix): The right-hand side matrix/vector `b` in `Ax = b`. - verbosity (int, optional): Level of verbosity (default is `1`): - - - 0: No output. - - 1: Display intermediate steps. - matrices (int, optional): - - - 1: Returns the least squares solution matrix. - - 2: Returns a [`PartGen`][(p).] with the part solution and general solution. - *args: Additional positional arguments passed to SymPy's - [`solve_least_squares`][sympy.matrices.matrixbase.MatrixBase.solve_least_squares] method. - **kwargs: Additional arguments passed to to SymPy's - [`solve_least_squares`][sympy.matrices.matrixbase.MatrixBase.solve_least_squares] method. - - Returns: - (Matrix): If `matrices=1`, returns the least squares solution matrix. - (PartGen): If `matrices=2`, returns a [`PartGen`][(p).] with the part solution and general solution. - - Examples: - >>> A = Matrix([[1, 0], [0, 0]]) - >>> b = Matrix([1, 2]) - >>> A.solve_least_squares(b, verbosity=0, matrices=1) - ... # in this case, least squares solution is not unique, - ... # so it returns a general solution. - Exception Encountered: Matrix must be non-singular. - Attempting custom solve... - Matrix([ - [1] - [y] - ]) - """ - - if verbosity == 0: - try: - A, b = sym.Matrix(self), sym.Matrix(rhs) - return A.solve_least_squares(rhs=b, *args, **kwargs) - except Exception as e: - print(f"Exception Encountered: {str(e)}") - print("Attempting custom solve...") - - ATA, ATb = self.T @ self, self.T @ rhs - if ATA.det() != 0 and verbosity >= 1: - print("self.T @ self is invertible. The least squares solution is unique.") - display( - "\\mathbf{x} = \\left(\\mathbf{A}^\\top \\mathbf{A}\\right)^{-1} \\mathbf{A}^\\top \\mathbf{b}", - opt="math", - ) - x = ATA.inv() @ ATb - display(x) - return x - - # Custom solve using sympy's solve method - sol = Matrix.create_unk_matrix(ATb.rows, 1) - sol = sol.subs(sym.solve(ATA @ sol - ATb, dict=True)[0]) - - if verbosity >= 1: - print("Before RREF: [self.T @ self | self.T @ rhs]") - aug_matrix = ATA.copy().row_join(ATb) - display(aug_matrix) - print("\nAfter RREF") - display(aug_matrix.rref()) - - if matrices == 1: - return sol - else: - return sol.sep_part_gen() - - @staticmethod - def create_vander( - num_rows: int = 1, num_cols: int = 1, symbol: str = "x", is_real: bool = True - ) -> Matrix: - """Creates a Vandermonde matrix with symbolic entries. - - This method generates a Vandermonde matrix of size `num_rows` x `num_cols` - where the entries are symbolic expressions. Each row in the matrix is formed - by raising a symbolic variable (indexed by row) to increasing powers (from 0 - to `num_cols-1`). The `is_real` flag determines whether the symbols are real-valued. - - Args: - num_rows (int, optional): The number of rows in the Vandermonde matrix. - num_cols (int, optional): The number of columns in the Vandermonde matrix. - symbol (str, optional): The base name for the symbols used in the matrix entries. - is_real (bool, optional): If True (default), the symbols are real-valued; - otherwise, they are complex. - - Returns: - (Matrix): A Vandermonde matrix with symbolic entries. - - Examples: - >>> Matrix.create_vander(2, 4, symbol='a') - Matrix([ - [1, a_1, a_1**2, a_1**3] - [1, a_2, a_2**2, a_2**3] - ]) - - See Also: - - [`apply_vander`][..] for applying the Vandermonde transformation to a matrix. - """ - - entries = sym.symbols(f"{symbol}_(1:{num_rows + 1})", is_real=is_real) - res = [] - for entry in entries: - sub_res = [] - for col_idx in range(num_cols): - # Raise the symbol to the power of the column index - sub_res.append(sym.Pow(entry, col_idx)) - res.append(sub_res) - return Matrix(res) - - def apply_vander(self, x: Matrix) -> Matrix: - """ - Applies a Vandermonde transformation to the current matrix using the given vector. - - This method applies a Vandermonde transformation to the current matrix by - substituting the free symbols in the last column with corresponding values - from the provided vector `x`. The number of rows in `self` must match the - number of elements in `x`, and `x` must be a column vector. - - Note: - - The matrix `self` is expected to be created via [`Matrix.create_vander()`][..create_vander]. - - The `x` vector provides the values to substitute in place of these symbols. - - Args: - x (Matrix): A column vector (Matrix object with a single column) containing - the values to substitute into the last column of the matrix. - - Returns: - (Matrix): A new Matrix object where the free symbols in the last column of - the original matrix are substituted by the corresponding values from `x`. - - Raises: - sympy.matrices.exceptions.ShapeError: If `x` is not a column vector or if - the number of rows in `self` does not match the size of `x`. - - Examples: - >>> mat = Matrix.create_vander(2, 2) - >>> x = Matrix([1, 2]) - >>> mat.apply_vander(x) - Matrix([ - [1, 1] - [1, 2] - ]) - """ - # Validate the size of the vector x - if x.cols != 1: - raise sym.ShapeError( - f"Input vector x must be a column vector. ({self.cols})" - ) - if self.rows != x.rows: - raise sym.ShapeError( - f"Number of rows in matrix ({self.rows}) must match the size of the input vector ({x.rows})" - ) - - # Get the free symbols from the last column of the matrix - ordered_syms = [entry.free_symbols.pop() for entry in self.select_cols(-1)] # type: ignore - - # Create a substitution dictionary mapping symbols to values from vector x - substitution = {var: val for var, val in zip(ordered_syms, x)} # type: ignore - return self.subs(substitution) - - ############################# - # CHAPTER 6: EIGEN-ANALYSIS # - ############################# - - def cpoly(self, force_factor: bool = True) -> Mul | tuple[Mul, Mul]: - """Computes the characteristic polynomial of the matrix and attempts to factor it into real and complex parts. - - The characteristic polynomial is defined as `det(x * I - self)`, where `I` is the identity matrix of the same size as `self`. - - Args: - force_factor (bool): If `True`, the polynomial is fully factored, even if it doesn't have real factors. - If `False`, the polynomial is returned in its factored form if possible. - - Returns: - (Mul): If the polynomial factors only into real terms, returns a single factored polynomial. - (tuple[Mul, Mul]): If the polynomial has both real and complex factors, returns a tuple of two polynomials - one with real factors and the other with complex factors. - - Examples: - >>> mat = Matrix([[-1, 0], [0, 4]]) - >>> mat.cpoly() - (x - 4)*(x + 1) - """ - x = sym.symbols("x", real=True) - poly = (x * self.elem() - self).det() - if not force_factor: - return poly.factor() - # Attempt to factor poly into real factors - try: - roots = sym.roots( - poly - ) # TODO: FIX sym.roots NotImplementedError for multi variable - real_fact = [] - for root, mult in roots.items(): - term = x - root - if mult != 1: - term = sym.Pow(term, mult, evaluate=False) - if root.is_real: - real_fact.append(term) - poly /= term - - linear_fact = Mul(*real_fact, evaluate=False) - complex_fact = poly.expand().cancel().factor() - - if complex_fact == 1: - return linear_fact # type: ignore - else: - return linear_fact, complex_fact # type: ignore - except Exception as error: - print(f"Encountered Error: {error}") - return poly.factor() - - # Override - def is_diagonalizable( - self, reals_only: bool = True, verbosity: int = 1, *args, **kwargs - ) -> bool: - """Checks if the matrix is diagonalizable, with the option to focus only on real eigenvalues. - - A matrix is diagonalizable if it has enough linearly independent eigenvectors to form a basis for the space. - - Args: - reals_only (bool, optional): If True, diagonalization will focus on real eigenvalues. - verbosity (int, optional): Controls the level of output during the diagonalization process. - - - 0: No output. - - 1: Displays the characteristic polynomial, eigenvalues, algebraic multiplicities, and eigenspaces. - - *args: Additional positional arguments passed to SymPy's - [`is_diagonalizable`][sympy.matrices.matrixbase.MatrixBase.is_diagonalizable] method. - **kwargs: Additional arguments passed to SymPy's - [`is_diagonalizable`][sympy.matrices.matrixbase.MatrixBase.is_diagonalizable] method. - - Returns: - (bool): True if the matrix is diagonalizable, False otherwise. - - Examples: - >>> mat = Matrix([[1, 2, 0], [0, 3, 0], [2, -4, 2]]) - >>> mat.is_diagonalizable(reals_only=True, verbosity=0) - True - - See Also: - - SymPy's [`Matrix.is_diagonalizable`][sympy.matrices.matrixbase.MatrixBase.is_diagonalizable] - - Sympy's [`Matrix.eigenvects`][sympy.matrices.matrixbase.MatrixBase.eigenvects] for computing eigenvalues and their multiplicities. - - [`diagonalize`][..] for diagonalizing the matrix. - """ - - # Changed default for reals_only to True to align with MA1522 syllabus - if verbosity >= 1: - print("Characteristic Polynomial is: ") - display(self.cpoly()) - print("\nCheck if algebraic multiplicity equals number of eigenvectors.\n") - print("Eigenvectors are:") - for val, mult, space in self.eigenvects(): - if (val.is_real and reals_only) or not reals_only: - res = { - "eigenvalue": val, - "algebraic multiplicity": mult, - "eigenspace": Matrix.from_list(space), - } - display(res, opt="dict") - - return super().is_diagonalizable(reals_only, *args, **kwargs) - - def eigenvects_associated( - self, eigenvalue: Expr | int | float - ) -> list[Matrix] | None: - """Computes the eigenvectors associated with a given eigenvalue. - - This method finds all (nonzero) vectors `v` such that `(eigenvalue * I - self) * v = 0` - where `I` is the identity matrix of the same size as `self`. - - Args: - eigenvalue (Expr | int | float): The eigenvalue for which to compute the associated eigenvectors. - - Returns: - (list[Matrix]): A list of eigenvectors (as Matrix objects) associated with the given eigenvalue, - (None): If the eigenvalue does not correspond to any eigenvectors. - - Examples: - >>> mat = Matrix([[2, 0], [0, 3]]) - >>> mat.eigenvects_associated(2) - [Matrix([ - [1], - [0]])] - """ - return (eigenvalue * self.elem() - self).nullspace() - - # Override - def diagonalize( - self, reals_only: bool = True, verbosity: int = 0, *args, **kwargs - ) -> PDP: - """Diagonalizes the matrix if possible, focusing on real eigenvalues unless specified otherwise. - - Args: - reals_only (bool, optional): If `True`, diagonalization will focus on real eigenvalues. - verbosity (int, optional): Controls the level of output during the diagonalization process. - - - 0: No output. - - 1: Displays the characteristic polynomial and eigenvectors. - *args: Additional positional arguments passed to SymPy's - [`diagonalize`][sympy.matrices.matrixbase.MatrixBase.diagonalize] method. - **kwargs: Additional arguments passed to SymPy's - [`diagonalize`][sympy.matrices.matrixbase.MatrixBase.diagonalize] method. - - Returns: - (PDP): A dataclass containing: - - - `P` ([`Matrix`][...]): The matrix of eigenvectors. - - `D` ([`Matrix`][...]): The diagonal matrix of eigenvalues. - - Raises: - sympy.matrices.matrixbase.MatrixError: If the matrix is not diagonalizable. - - Examples: - >>> mat = Matrix([[1, 2], [3, 4]]) - >>> mat.diagonalize() - PDP(P=Matrix([ - [-sqrt(33)/6 - 1/2, -1/2 + sqrt(33)/6] - [ 1, 1] - ]), D=Matrix([ - [5/2 - sqrt(33)/2, 0] - [ 0, 5/2 + sqrt(33)/2] - ])) - """ - - # Changed default for reals_only to True to align with MA1522 syllabus - if verbosity >= 1: - print("Characteristic Polynomial") - poly = self.cpoly() - display(poly) - for root, _ in sym.roots(poly).items(): - if root.is_real: - display( - _textify("Before RREF: ") - + sym.latex(root) - + r"\mathbb{I} - \mathrm{self}", - opt="math", - ) - expr = root * self.elem() - self - display(expr) - - print("\nAfter RREF:") - display(expr.rref()) - - print("\nEigenvectors:") - display(expr.nullspace()) - print("\n") - - P, D = super().diagonalize(reals_only, *args, **kwargs) - P.rm_aug_line() # Remove augmented line if exists - return PDP(P, D) - - def is_orthogonally_diagonalizable(self, verbosity: int = 2) -> bool: - """Determines whether the matrix is orthogonally diagonalizable. - - A matrix is orthogonally diagonalizable if and only if it is symmetric. - This method checks the symmetry of the matrix and optionally displays - diagnostic information based on the verbosity level. - - Args: - verbosity (int, optional): Level of diagnostic output. - - - 0: No output. - - 1: Displays the matrix. - - 2: Displays the result of the symmetry check. - - Returns: - (bool): True if the matrix is symmetric (orthogonally diagonalizable), False otherwise. - - Examples: - >>> mat = Matrix([[1, 2], [2, 1]]) - >>> mat.is_orthogonally_diagonalizable(verbosity=0) - True - - See Also: - - SymPy's [`Matrix.is_symmetric`][sympy.matrices.matrixbase.MatrixBase.is_symmetric] - - """ - if verbosity: - print(f"Check if matrix is symmetric: {self.is_symmetric()}") - if verbosity >= 2: - print("\nCheck if self == self^T:") - display(self == self.T) - return self.is_symmetric() - - # Override - def orthogonally_diagonalize( - self, reals_only: bool = True, factor: bool = True, verbosity=1, *args, **kwargs - ) -> PDP: - """Orthogonally diagonalizes the matrix, ensuring that eigenvectors corresponding to different eigenvalues are orthogonal. - - Args: - reals_only (bool): If True, only real eigenvalues are considered. - factor (bool): If True, the eigenvectors are orthogonalized using the Gram-Schmidt process. - verbosity (int): Controls the verbosity of output during the process. - *args: Additional positional arguments passed to the [`diagonalize`][..] method. - **kwargs: Additional arguments passed to the [`diagonalize`][..] method. - - Returns: - (PDP): A dataclass containing: - - - `P` ([`Matrix`][...]): The orthogonal matrix of eigenvectors. - - `D` ([`Matrix`][...]): The diagonal matrix of eigenvalues. - - Raises: - AssertionError: If the matrix is not orthogonally diagonalizable (i.e., not symmetric). - - Examples: - >>> mat = Matrix([[1, 2], [2, 1]]) - >>> mat.orthogonally_diagonalize(factor=False, verbosity=0) - PDP(P=Matrix([ - [-sqrt(2)/2, sqrt(2)/2] - [ sqrt(2)/2, sqrt(2)/2] - ]), D=Matrix([ - [-1, 0] - [ 0, 3] - ])) - - See Also: - - [`is_orthogonally_diagonalizable`][..] to check if the matrix is orthogonally diagonalizable. - - [`diagonalize`][..] for diagonalizing the matrix. - """ - - # Changed default for reals_only to True to align with MA1522 syllabus - # Note that you can just apply GSP on P directly, since eigenspace associated to different eigenvalues are orthogonal - # However, we follow the steps given in MA1522 syllabus here - assert self.is_orthogonally_diagonalizable(verbosity=verbosity) - # P, D = super().diagonalize(reals_only, *args, **kwargs) - P, D = self.diagonalize( - reals_only=reals_only, verbosity=verbosity, *args, **kwargs - ) - - d: DefaultDict[Expr, list[Matrix]] = defaultdict(list) - for vec, val in zip(P.columnspace(), D.diagonal()): - d[val].append(vec) - - result = [] - for val, vecs in d.items(): - if len(vecs) > 1: - # Require Gram Schmidt to ensure eigenvectors are orthogonal - if verbosity >= 1: - print("Eigenvalue: ", val) - print("[Gram Schmidt Process]") - if factor: - gram_result = Matrix.from_list(vecs).gram_schmidt( - factor=True, verbosity=verbosity - ) - if isinstance(gram_result, ScalarFactor): - result.append(gram_result.eval()) - elif isinstance(gram_result, Matrix): - result.append(gram_result) - else: - raise TypeError( - f"Unexpected return type from gram_schmidt: {type(gram_result)}" - ) - else: - result.append( - Matrix.from_list(vecs).gram_schmidt(factor, verbosity) - ) - else: - result.append(vecs[0].normalized()) - - if len(result) == 0: - ortho_P = P - else: - ortho_P = result[0] - for m in result[1:]: - ortho_P = ortho_P.row_join(m, aug_line=False) - - assert (ortho_P @ D @ ortho_P.T - self).norm() == 0 - return PDP(ortho_P, D) - - def is_stochastic(self, verbosity: int = 1) -> bool: - """Checks if the matrix is stochastic. - - A matrix is stochastic if all its entries are non-negative and each column sums to 1. - This property is commonly used to identify transition matrices in Markov chains. - - Args: - verbosity (int, optional): Level of diagnostic output. - - - 0: No output. - - 1: Displays the result of the checks. - - Returns: - (bool): True if the matrix is stochastic, False otherwise. - - Examples: - >>> mat = Matrix([[0.5, 0.1], [0.5, 0.9]]) - >>> mat.simplify(rational=True) # Convert floats to symbolic fractions - >>> mat.is_stochastic(verbosity=0) - True - - >>> mat = Matrix([[1.1, 1], [-0.1, 0]]) - >>> mat.is_stochastic(verbosity=0) - False - """ - is_square = self.rows == self.cols - is_non_negative = all(entry >= 0 for entry in self.flat()) - is_prob_vectors = all(sum(self[:, i]) == 1 for i in range(self.cols)) # type: ignore - if verbosity >= 1: - print(f"Check if matrix is square: {is_square}") - print(f"Check if all entries are non-negative: {is_non_negative}") - print(f"Check if each column sums to 1: {is_prob_vectors}") - return is_square and is_non_negative and is_prob_vectors - - def equilibrium_vectors(self) -> Matrix: - """Computes the equilibrium vectors of the matrix, i.e., the nullspace of (I - A). - - Note: - - A matrix `P` has a unique equilibrium vector if it is stochastic and - there exists some positive integer `k` such that `P^k` only has positive entries. - - Returns: - (Matrix): A matrix containing equilibrium vectors normalized so that their - column sums to 1. - - Examples: - >>> mat = Matrix([[0.1, 0.9], [0.9, 0.1]]) - >>> mat.simplify() - >>> mat.equilibrium_vectors() - Matrix([ - [1/2] - [1/2] - ]) - """ - - P = Matrix.from_list((self.elem() - self).nullspace()) - for i in range(P.cols): - if sum(P[:, i]) != 0: # type: ignore - P[:, i] /= sum(P[:, i]) # type: ignore - return P - - def singular_value_decomposition( - self, verbosity: int = 0, tol: float = 0.0, verify: bool = True - ) -> SVD: - """Performs Singular Value Decomposition (SVD) on the matrix, following the MA1522 syllabus. - - Note: - - This function is known to take too much time and may kill Jupyter's kernel. Please use it with caution. - A workaround is to set `verify=False` to skip the verification step, or use the faster numerical SVD - method [`fast_svd`][..fast_svd] instead. - - Args: - verbosity (int, optional): Controls the verbosity of the output. - - - 0: No output. - - 1: Displays intermediate steps and results of the SVD process. - tol (float, optional): Tolerance for verification of the SVD result. - verify (bool): If `True`, verifies the result of the SVD by checking if `self = U @ S @ V.T`. - If `False`, skips the verification step for performance reasons. - - Returns: - (SVD): A dataclass containing: - - - `U` ([`Matrix`][...]): The left singular vectors. - - `S` ([`Matrix`][...]): The diagonal matrix of singular values. - - `V` ([`Matrix`][...]): The right singular vectors. - - Such that `self = U @ S @ V.T`. - - Examples: - >>> mat = Matrix([[3, 2, 2], [2, 3, -2]]) - >>> mat.singular_value_decomposition(verbosity=0, verify=False) - SVD(U=Matrix([ - [sqrt(2)/2, -sqrt(2)/2] - [sqrt(2)/2, sqrt(2)/2] - ]), S=Matrix([ - [5, 0, 0] - [0, 3, 0] - ]), V=Matrix([ - [sqrt(2)/2, -sqrt(2)/6, 2/3] - [sqrt(2)/2, sqrt(2)/6, -2/3] - [ 0, -2*sqrt(2)/3, -1/3] - ])) - - See Also: - - [`fast_svd`][..fast_svd] for a faster numerical SVD - - SymPy's [`Matrix.singular_value_decomposition`][sympy.matrices.matrixbase.MatrixBase.singular_value_decomposition] - """ - - if verbosity >= 1: - AT_A = self.T @ self - print("A^T A") - display(AT_A) - P, D = AT_A.orthogonally_diagonalize(verbosity=verbosity) - # Reverse index such that singular values are in decreasing order - sigma = [sym.sqrt(val) for val in D.diagonal()][::-1] - S = Matrix.diag(*[singular for singular in sigma if (singular != 0)]) - V = P.select_cols(*[i for i in range(P.cols)][::-1]) - - u_list = [] - for idx, vec, val in zip(range(1, S.rows + 1), V.columnspace(), sigma): - if val != 0: - u_i = self @ vec / val - u_list.append(u_i) - display( - f"u_{idx} = (1/{sym.latex(val)})A{sym.latex(vec)} = {sym.latex(u_i)}", - opt="math", - ) - - U = Matrix.from_list(u_list) - # Extend basis using orthogonal complement and gram-schmidt if insufficient vectors - if U.cols < self.rows: - print("\nExtending U with its orthogonal complement.") - if U.cols == 0: - # Pad edge case with identity - orth = Matrix.eye(self.rows) - else: - complement = U.orthogonal_complement(verbosity=verbosity) - gram_result = complement.gram_schmidt( - factor=True, verbosity=verbosity - ) - if isinstance(gram_result, ScalarFactor): - orth = gram_result.full - else: - orth = gram_result - - orth = orth.normalized(factor=False) - assert isinstance(orth, Matrix), ( - f"Expected orth to be a Matrix, got {type(orth)}" - ) - U = U.row_join(orth, aug_line=False) - - # Add zero rows and columns to S so that matrix multiplication is defined - m, n = self.shape - r, c = S.shape - S = S.row_join(sym.zeros(r, n - c), aug_line=False).col_join( - sym.zeros(m - r, n) - ) - - if verify: - assert (U @ S @ V.T - self).norm() == 0 - return SVD(U, S, V) - - m, n = self.shape - U, S, V = super().singular_value_decomposition() - # Reverse index such that singular values are in decreasing order - new_S = Matrix.diag(*S.diagonal()[::-1]) - - S_index = [i for i in range(S.cols)][::-1] - new_U = Matrix(U).select_cols(*S_index) - new_V = Matrix(V).select_cols(*S_index) - - # new_U = Matrix(U). - # Add orthonormal columns to U and V so that they are square matrices - new_U = new_U.QRdecomposition(full=True).Q - new_V = new_V.QRdecomposition(full=True).Q - - # Add zero rows and columns to S so that matrix multiplication is defined - r, c = new_S.shape - new_S = new_S.row_join(sym.zeros(r, n - c), aug_line=False).col_join( - sym.zeros(m - r, n) - ) - - if verify and (residues := (new_U @ new_S @ new_V.T - self).norm()) > tol: - res = residues.evalf() - warn( - f"Verification failed: norm of residual is {res} > {tol}", - RuntimeWarning, - stacklevel=2, - ) - return SVD(new_U, new_S, new_V) - - def fast_svd( - self, - option: Literal["np", "sym"] = "np", - identify: bool = True, - tol: float | None = None, - ) -> SVD | NumSVD: - """A faster version of SVD that computes numerically using NumPy's SVD function. - - This method is designed to be efficient and suitable for large matrices, but it does not guarantee - exact symbolic results like the [`singular_value_decomposition`][..singular_value_decomposition] method. - It uses [`numpy.linalg.svd`][numpy.linalg.svd] function to compute the singular value decomposition and - [`mpmath.identify`][mpmath.identify] function to identify rational numbers or surds if requested. - - Note: - - This method might not return exact values, even if identification is enabled as it does - not use SymPy's symbolic computation for SVD. - - Use this method when performance is a concern and exact symbolic results are not required. - - Args: - option (Literal["np", "sym"], optional): Whether to return numpy arrays or sympy matrices. - identify (bool, optional): Whether to attempt identification of rational numbers or surds. - If `True`, `option` must be `"sym"` to return symbolic matrices. - tol (float, optional): Tolerance for [`mpmath.identify`][mpmath.identify] function as well as - for verifying the SVD result. - - Returns: - (SVD): A dataclass containing: - - - `U` ([`Matrix`][...]): The left singular vectors. - - `S` ([`Matrix`][...]): The diagonal matrix of singular values. - - `V` ([`Matrix`][...]): The right singular vectors. - - (NumSVD): A named tuple containing: - - - `U` ([`numpy.ndarray`][numpy.ndarray]): The left singular vectors as a NumPy array. - - `S` ([`numpy.ndarray`][numpy.ndarray]): The diagonal matrix of singular values as a NumPy array. - - `V` ([`numpy.ndarray`][numpy.ndarray]): The right singular vectors as a NumPy array. - - Examples: - >>> mat = Matrix([[3, 2, 2], [2, 3, -2]]) - >>> mat.fast_svd(option="sym", identify=False) # doctest: +SKIP - SVD(U=Matrix([ - [-0.707106781186548, -0.707106781186548] - [-0.707106781186547, 0.707106781186548] - ]), S=Matrix([ - [5.0, 0.0, 0.0] - [0.0, 3.0, 0.0] - ]), V=Matrix([ - [ -0.707106781186548, -0.235702260395516, -0.666666666666667] - [ -0.707106781186548, 0.235702260395516, 0.666666666666667] - [-6.47932334256779e-17, -0.942809041582063, 0.333333333333333] - ])) - - See Also: - - [`singular_value_decomposition`][..singular_value_decomposition] for the - symbolic version of SVD. - - NumPy's [`numpy.linalg.svd`][numpy.linalg.svd] for the underlying numerical - SVD implementation. - """ - - m, n = self.shape - U, S, Vh = np.linalg.svd(np.array(self, dtype=np.float64)) - # To align with MA1522 Syllabus, return V instead of V.T - # Need not use conjugate transpose as MA1522 deals with real matrices - V = Vh.T - - # Create sigma matrix from singular values - S = np.diag(S) - r, c = S.shape - S = np.concat((S, np.zeros((r, n - c))), axis=1) - S = np.concat((S, np.zeros((m - r, n))), axis=0) - if option == "np": - return NumSVD(U, S, V) - elif option == "sym": - U, S, V = Matrix(U), Matrix(S), Matrix(V) - if identify: - U = U.identify(tol=tol, suppress_warnings=True) - S = S.identify(tol=tol, suppress_warnings=True) - V = V.identify(tol=tol, suppress_warnings=True) - residues = (self - U @ S @ V.T).norm() - _tol = tol if tol is not None else 1e-15 - if residues > _tol: - res = residues.evalf() - warn( - f"Non-zero Identification Error: {res}", - RuntimeWarning, - stacklevel=2, - ) - return SVD(U, S, V) - else: - return SVD(U, S, V) - else: - warn( - f"Invalid option '{option}'. Expected 'np' or 'sym'. Returning NumSVD.", - SyntaxWarning, - stacklevel=2, - ) - return NumSVD(U, S, V) - - #################################### - # CHAPTER 7: LINEAR TRANSFORMATION # - #################################### - - def standard_matrix( - self, out: Matrix, matrices: int = 1 - ) -> list[Matrix] | list[PartGen]: - """Returns the standard matrix for the transformation from self to out. - - The standard matrix is a matrix `T` such that `T @ self = out`, where `self` is the matrix - whos columns represent the input vectors and `out` is the matrix whose columns represent the output vectors. - - Note: - - The standard matrix may not be unique if the transformation is not injective. - - If multiple solutions are found, the first solution is returned. - - Args: - out (Matrix): The target matrix for the transformation. - matrices (int): The type of matrices to return: - - - 1: Returns the standard matrix. - - 2: Returns a [`PartGen`][(p).] with the part solution and general solution. - - Returns: - (list[Matrix]): If `matrices=1`, returns the standard matrix for the transformation. - (list[PartGen]): If `matrices=2`, returns a [`PartGen`][(p).] with the part solution and general solution. - - Examples: - >>> input = Matrix([[1, 0, 1], [2, -1, 0], [0, 3, 1]]) - >>> output = Matrix([[4, 2, 3], [5, -1, 0], [1, 4, 2]]) - >>> input.standard_matrix(output) - [Matrix([ - [ 2, 1, 1] - [-3/5, 14/5, 3/5] - [ 3/5, 1/5, 7/5] - ])] - """ - X = Matrix.create_unk_matrix(r=out.rows, c=self.rows) - equal_0 = X @ self - out - if len(self.free_symbols) != 0: - # Add zeroing examples to condition the transformation matrix for unknown vectors - examples = [] - for symbol in (symbols := self.free_symbols): - sub = {s: 0 for s in symbols if s != symbol} - sub[symbol] = 1 - examples.append(equal_0.subs(sub)) - - # for _ in range(X.rows * X.cols): - # # Add random examples to condition the transformation matrix - # rand = Matrix.create_rand_matrix(r=len(self.free_symbols), c=1) - # sub = {s: rand[i, 0] for i, s in enumerate(symbols)} - # examples.append(equal_0.subs(sub)) - - condition = Matrix.from_list(examples) - equal_0 = equal_0.row_join(condition, aug_line=False) - - sol = sym.solve(equal_0, X.free_symbols, dict=True) - if len(sol) == 0: - raise ValueError( - "No solution found for the standard matrix. " - "This may indicate that the transformation is not linear." - ) - res = [] - for s in sol: - tmp = X.subs(s) - if matrices == 1: - res.append(tmp) - elif matrices == 2: - res.append(tmp.sep_part_gen()) - else: - raise ValueError( - f"Invalid value for matrices: {matrices}. Expected 1 or 2." - ) - return res From db185435fa9854eacb0cccad9a999314fdc267fb Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Mon, 11 May 2026 19:57:06 +0800 Subject: [PATCH 09/11] Undo accidental deletion of symbolic.py --- src/ma1522/symbolic.py | 4445 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 4445 insertions(+) create mode 100644 src/ma1522/symbolic.py diff --git a/src/ma1522/symbolic.py b/src/ma1522/symbolic.py new file mode 100644 index 0000000..416e8a9 --- /dev/null +++ b/src/ma1522/symbolic.py @@ -0,0 +1,4445 @@ +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Iterable +from typing import TYPE_CHECKING +from warnings import warn + +import sympy as sym +from sympy.parsing.sympy_parser import parse_expr + +import numpy as np +import mpmath as mp +from latex2sympy2 import latex2sympy +import re + +# import IPython.display + +from .utils import _is_zero, _standardise_symbol, _textify, display + +from .custom_types import ( + Shape, + PartGen, + ScalarFactor, + PLU, + RREF, + RREFCase, + VecDecomp, + QR, + PDP, + SVD, + NumSVD, +) + +if TYPE_CHECKING: + from typing import Callable, Literal, DefaultDict + +from sympy.core.expr import Expr +from sympy.core.symbol import Symbol +from sympy.core.mul import Mul + +sym.init_printing(use_unicode=True) +np.set_printoptions(formatter={"float": lambda x: f"{x:10.7g}"}) + + +# --------------------------------------------------------------------------- + + +class Matrix(sym.MutableDenseMatrix): + r"""A symbolic matrix class extending [`MutableDenseMatrix`][sympy.matrices.dense.MutableDenseMatrix] with enhanced linear algebra operations. + + The inherited methods from [`MutableDenseMatrix`][sympy.matrices.dense.MutableDenseMatrix] + can be found in the [SymPy Matrices Documentation](https://docs.sympy.org/latest/modules/matrices/matrices.html). A summary of the + inherited attributes and methods is also available on the [Inherited Methods Summary](inherited.md) page. + + This class provides comprehensive linear algebra functionality with support for: + - Matrix creation from various sources (lists, $\rm\LaTeX$, random values) + - Matrix decompositions (REF, RREF, LU, QR, SVD, diagonalization) + - Vector space operations (orthogonalization, projections, basis manipulation) + - Eigenvalue/eigenvector computations + - Custom printing and $\rm\LaTeX$ formatting with augmented matrix support + + Key Features: + - Maintains symbolic expressions throughout operations + - Follows MA1522 syllabus conventions for linear algebra + - Provides detailed step-by-step output for learning purposes + - Supports both exact symbolic and numerical computations + + Attributes: + _aug_pos (set[int]): Set of column indices where augmentation lines should be drawn + for displaying augmented matrices. + + Examples: + Basic matrix operations: + >>> A = Matrix([[1, 2], [3, 4]]) + >>> A.rref() + RREF(rref=Matrix([ + [1, 0] + [0, 1] + ]), pivots=(0, 1)) + + Creating from LaTeX: + >>> B = Matrix.from_latex(r'\begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix}') + ⎡1 2⎤ + ⎢ ⎥ + ⎣3 4⎦ + + Eigenvalue decomposition: + >>> P, D = A.diagonalize(verbosity=0) + >>> P, D + (Matrix([ + [-sqrt(33)/6 - 1/2, -1/2 + sqrt(33)/6] + [ 1, 1] + ]), Matrix([ + [5/2 - sqrt(33)/2, 0] + [ 0, 5/2 + sqrt(33)/2] + ])) + """ + + def __init__( + self, + *args, + aug_pos: Iterable[int] | int | None = None, + **kwargs, + ) -> None: + if aug_pos is None: + self._aug_pos = set() + elif isinstance(aug_pos, int): + self._aug_pos = set([aug_pos]) + elif isinstance(aug_pos, Iterable) and all(isinstance(i, int) for i in aug_pos): + self._aug_pos = set(aug_pos) + else: + raise TypeError( + f"Invalid type for aug_pos: {type(aug_pos)}. Expected Iterable[int]." + ) + + def __str__(self) -> str: + res = super().__str__() + if not hasattr(self, "_aug_pos"): + # Matrices produced by parent methods may not have _aug_pos + return res + aug_pos = f"aug_pos: {self._aug_pos}" + return f"{res}, {aug_pos}" + + def __repr__(self) -> str: + if not hasattr(self, "_aug_pos"): + # Matrices produced by parent methods may not have _aug_pos + return super().__repr__() + + def rep_row(row: str, pos_set: set[int]) -> str: + repr = "" + elems = row.removesuffix(",").split(",") + for idx, elem in enumerate(elems): + if idx in pos_set: + repr += elem + " |" + else: + repr += elem + "," + return repr.removesuffix("|").removesuffix(",") + + res = super().__repr__().removeprefix("Matrix([").removesuffix("])") + res_row_list = (rep_row(row, self._aug_pos) for row in res.split("\n")) + return "Matrix([" + "\n".join(res_row_list) + "\n])" + + def __eq__(self, other) -> bool: + if not hasattr(self, "_aug_pos") or not hasattr(other, "_aug_pos"): + return super().__eq__(other) + + return super().__eq__(other) and (self._aug_pos == other._aug_pos) + + # Override + def _latex(self, printer=None) -> str: + if printer is None: + raw = sym.latex(sym.Matrix(self)) + else: + raw = printer._print(sym.Matrix(self)) # type: ignore + + if not hasattr(self, "_aug_pos"): + # Matrices produced by parent methods may not have _aug_pos + return raw + + # get latex representation of matrix with "array" format + raw = sym.latex(sym.Matrix(self), mat_str="array") + array_c = "\\begin{array}{" + "c" * self.cols + "}" + for mat_str in ["smallmatrix", "matrix"]: + old_beg = "\\begin{" + mat_str + "}" + old_end = "\\end{" + mat_str + "}" + raw = raw.replace(old_beg, array_c).replace(old_end, "\\end{array}") + + ls = [ + pos for pos in self._aug_pos if 0 <= pos < self.cols - 1 + ] # remove trailing pos + if len(ls) == 0: + # no valid _aug_pos found + return raw + ls.sort() + + # create formatting string s to insert augment line visually + delta = [ls[0]] + delta.extend([ls[i] - ls[i - 1] for i in range(1, len(ls))]) + remainder = self.cols - sum(delta) - 1 + delta.append(remainder) + s = "\\begin{array}{c" + "|".join(("c" * i for i in delta)) + "}" + array_c = "\\begin{array}{" + "c" * self.cols + "}" + + return raw.replace(array_c, s) + + ################### + # FACTORY METHODS # + ################### + @staticmethod + def from_latex( + expr: str, + verbosity: int = 1, + row_join: bool = True, + norm: bool = False, + aug_pos: Iterable[int] | int | None = None, + ) -> Matrix: + r"""Converts a $\rm\LaTeX$ matrix/vector expression into a Matrix object. + + Parses $\rm\LaTeX$ matrix environments (pmatrix, array) and vector lists into a Matrix. + Handles matrix multiplication expressions and normalizes vectors when requested. + + Args: + expr (str): $\rm\LaTeX$ string containing: + + - Matrix environments: + `\begin{pmatrix} ... \end{pmatrix}`, `\begin{array}{ccc} ... \end{array}` + - Vector lists: + `\{ \begin{pmatrix} ... \end{pmatrix}, \begin{pmatrix} ... \end{pmatrix}\}` + - Matrix products: + `\begin{pmatrix}...\end{pmatrix}\begin{pmatrix} ... \end{pmatrix}` + + verbosity (int): Controls output detail level: + - 0: No output + - 1: Display parsed matrix + + row_join (bool): If True, vector lists are treated as columns. + If False, vectors are treated as rows. + + norm (bool): If True, normalizes vectors to unit length. + + aug_pos (Iterable[int] | int | None): If provided, specifies the column indices + where augmentation lines should be drawn in the output matrix. This is useful for displaying + augmented matrices in a visually clear manner. If `None`, no augmentation lines are drawn. + + Returns: + (Matrix): The parsed matrix with optional normalization. + + Raises: + Exception: If the $\rm\LaTeX$ expression is empty, invalid or cannot be parsed. + + Examples: + >>> Matrix.from_latex(r'\begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix}', verbosity=0) + Matrix([ + [1, 2] + [3, 4] + ]) + + >>> Matrix.from_latex(r'\begin{pmatrix} 1 \\ 2 \\ 3 \end{pmatrix}', verbosity=0, norm=True) + Matrix([ + [ sqrt(14)/14] + [ sqrt(14)/7] + [3*sqrt(14)/14] + ]) + """ + + # Step 1: Modify the LaTeX string to ensure compatibility with the parser. + # Convert array-like LaTeX to pmatrix for proper matrix formatting + # Replace \begin{array}{ccc*} with \begin{pmatrix} + modified_latex = re.sub( + r"\\begin\{array\}(?:\{[^}]*\})?", r"\\begin{pmatrix}", expr + ) + # Replace \end{array} with \end{pmatrix} + modified_latex = re.sub(r"\\end\{array\}", r"\\end{pmatrix}", modified_latex) + # Remove LaTeX semicolon for cleaner parsing + modified_latex = re.sub(r"\\;", "", modified_latex) + + # Step 2: Use latex2sympy to parse the modified LaTeX expression into SymPy Matrix + res = latex2sympy(modified_latex) + if verbosity >= 1: + display(res) + + # Step 3: Handle the parsed result based on its type (list, MatMul, or Matrix) + mat = Matrix() + if isinstance(res, list): + vector_list = [] + for vector in res: + vector = vector.expand() + vector_list.append(vector) + mat = Matrix.from_list(vector_list, row_join) + elif isinstance(res, sym.MatMul): + # If the matrix is a product of matrices, evaluate the product directly + mat = Matrix(res.doit()) + elif isinstance(res, sym.Matrix): + # Directly converts the SymPy Matrix into the custom Matrix object to inherit the custom methods + mat = Matrix(res) + else: + # If the result is neither a list nor a matrix expression, return the raw result + mat = res + + if aug_pos is not None: + mat = Matrix(mat, aug_pos=aug_pos) + + # Step 4: Normalisation + if norm and isinstance(mat, Matrix): + return mat.normalized(factor=False) # type: ignore + return mat + + @staticmethod + def from_str( + matrix_str: str, + row_sep: str = ";", + col_sep: str = " ", + aug_pos: Iterable[int] | int | None = None, + is_real: bool = True, + ) -> Matrix: + """Parses a string representation of a matrix into a Matrix. + + This method enables quick creation of a Matrix object from a string format similar to + the one used in MATLAB. It supports both row and column separators and + uses SymPy's [`parse_expr`][sympy.parsing.sympy_parser.parse_expr] to convert + the entries of the matrix from a string format into a Matrix object. + + Args: + matrix_str (str): The string representation of the matrix. + row_sep (str, optional): The separator for rows in the string. + col_sep (str): The separator for columns in the string. + aug_pos (Iterable[int] | int | None, optional): If provided, specifies the column indices + where augmentation lines should be drawn in the output matrix. This is useful for displaying + augmented matrices in a visually clear manner. If `None`, no augmentation lines are drawn. + is_real (bool, optional): If True, the symbols in the matrix are considered real-valued. Otherwise, they are complex. + + Returns: + (Matrix): A Matrix object representing the parsed matrix. + + Raises: + SyntaxError: If the string cannot be parsed into a matrix. + + Examples: + >>> Matrix.from_str("[1 2; 3 4]") + Matrix([ + [1, 2] + [3, 4] + ]) + """ + + matrix_str = ( + matrix_str.strip().removeprefix("[").removesuffix("]") + ) # remove surrounding brackets + rows = matrix_str.strip().split(row_sep) + matrix = [] + for row in rows: + cols = row.strip().split(col_sep) + matrix.append([parse_expr(col.strip()) for col in cols]) + + if aug_pos is not None: + matrix = Matrix(matrix, aug_pos=aug_pos) + else: + matrix = Matrix(matrix) + + symbols = matrix.free_symbols + new_symbols = _standardise_symbol(symbols, is_real=is_real) + matrix = matrix.subs({s: n for s, n in zip(symbols, new_symbols)}) + return matrix + + @staticmethod + def from_list( + vectors: list[Matrix], + row_join: bool = True, + aug_pos: Iterable[int] | int | None = None, + ) -> Matrix: + """Creates a Matrix object from a list of vectors. + + This method takes a list of vectors (each represented as a Matrix object) + and combines them into a single matrix. + + Args: + vectors (list[Matrix]): A list of Matrix objects, where each Matrix + represents a row or column vector. + + row_join (bool, optional): If True, the vectors are joined horizontally. + If False, the vectors are stacked vertically. + + aug_pos (Iterable[int] | int | None, optional): If provided, specifies the column indices + where augmentation lines should be drawn in the output matrix. This is useful for displaying + augmented matrices in a visually clear manner. If `None`, no augmentation lines are drawn. + + Returns: + (Matrix): A matrix constructed from the list of vectors. + + Raises: + sympy.matrices.exceptions.ShapeError: If the vectors do not have compatible dimensions for joining. + + Examples: + >>> vec1 = Matrix([[1], [2]]) + >>> vec2 = Matrix([[3], [4]]) + >>> Matrix.from_list([vec1, vec2]) + Matrix([ + [1, 3] + [2, 4] + ]) + + >>> Matrix.from_list([vec1, vec2], row_join=False) + Matrix([ + [1] + [2] + [3] + [4] + ]) + """ + if not vectors: + return Matrix([]) + res = Matrix(vectors[0]) + for vec in vectors[1:]: + if row_join: + res = res.row_join(vec, aug_line=False) + else: + res = res.col_join(vec) + if aug_pos is not None: + return Matrix(res, aug_pos=aug_pos) + return res + + def _shape(self, shape: Shape) -> Matrix: + match shape: + case Shape.DIAGONAL: + res = Matrix.diag(*self.diagonal()) + if self.rows > self.cols: + res = res.col_join(Matrix.zeros(self.rows - self.cols, self.cols)) + elif self.rows < self.cols: + res = res.row_join( + Matrix.zeros(self.rows, self.cols - self.rows), aug_line=False + ) + return res + case Shape.SCALAR: + if self.rows != self.cols: + raise sym.NonSquareMatrixError( + "Scalar shape is only defined for square matrices." + ) + return self.diagonal()[0] * self.elem() + case Shape.UPPER: + return self.upper_triangular() + case Shape.LOWER: + return self.lower_triangular() + case Shape.STRICT_UPPER: + return self._shape(Shape.UPPER) - self._shape(Shape.DIAGONAL) + case Shape.STRICT_LOWER: + return self._shape(Shape.LOWER) - self._shape(Shape.DIAGONAL) + case Shape.SYMMETRIC: + if self.rows != self.cols: + raise sym.NonSquareMatrixError( + "Symmetric shape is only defined for square matrices." + ) + return self._shape(Shape.UPPER) + self._shape(Shape.STRICT_UPPER).T + + @staticmethod + def create_unk_matrix( + r: int = 1, + c: int = 1, + symbol: str | None = None, + is_real: bool | None = True, + shape: Shape | None = None, + ) -> Matrix: + r"""Creates a symbolic matrix with unknown entries. + + This method generates a matrix of size $r \times c$ with symbolic + entries. The entries are named based on the provided `symbol` parameter and + indexed by their row and column positions. The `is_real` flag determines whether + the symbols are real-valued. + + Note: + - For a column vector without a specified symbol, the entries will be named + following conventions, i.e., $\begin{pmatrix} x \end{pmatrix}$, + $\begin{pmatrix} x \\ y \end{pmatrix}$, + $\begin{pmatrix} x \\ y \\ z \end{pmatrix}$, for 1, 2 and 3 rows respectively. + + Args: + r (int, optional): The number of rows in the matrix. + c (int, optional): The number of columns in the matrix. + symbol (str, optional): The base name for the symbols used in the matrix entries. + is_real (bool, optional): If True, the symbols are real-valued. Otherwise, they are complex. + shape (Shape, optional): If provided, the matrix will be reshaped to this + specific shape. Supported shapes include: + + - [DIAGONAL][(p).Shape.DIAGONAL]: Returns a diagonal matrix. + - [SCALAR][(p).Shape.SCALAR]: Returns a scalar matrix. + - [UPPER][(p).Shape.UPPER]: Returns an upper triangular matrix. + - [LOWER][(p).Shape.LOWER]: Returns a lower triangular matrix. + - [STRICT_UPPER][(p).Shape.STRICT_UPPER]: Returns an upper triangular matrix without the diagonal. + - [STRICT_LOWER][(p).Shape.STRICT_LOWER]: Returns a lower triangular matrix without the diagonal. + - [SYMMETRIC][(p).Shape.SYMMETRIC]: Returns a symmetric matrix. + + Returns: + (Matrix): A matrix with symbolic entries of the specified size. + + Raises: + sympy.matrices.exceptions.NonSquareMatrixError: If `shape` is ill-defined on a non-square matrix. + + Examples: + >>> Matrix.create_unk_matrix(2, 2, symbol='a') + Matrix([ + [a_1,1, a_1,2], + [a_2,1, a_2,2]]) + + >>> Matrix.create_unk_matrix(3, 1, symbol='y') + Matrix([ + [y_1] + [y_2] + [y_3] + ]) + + See Also: + - [`create_rand_matrix`][..]: Creates a matrix with random entries. + """ + + # Create a vector of size rows with entries (x, y) or (..., y, z) + if r <= 26 and c == 1 and symbol is None: + ls = [] + match r: + case 1: + ls = list("x") + case 2: + ls = list("xy") + case 3: + ls = list("xyz") + case _: + ascii_lowercase = list("abcdefghijklmnopqrstuvwxyz") + ls = ascii_lowercase[26 - r :] + entries = sym.symbols(ls, real=is_real) + return Matrix(entries) + + if symbol and c == 1: + entries = sym.symbols(f"{symbol}_(1:{r + 1})", real=is_real) + return Matrix(entries) + + # Creates a matrix of size rows * cols with entries symbol_i,j + symbol = symbol or "x" # default is "x" + entries = sym.symbols(f"{symbol}_(1:{r + 1})\\,(1:{c + 1})", real=is_real) + + res = Matrix(entries).reshape(r, c) + if shape: + return res._shape(shape) + else: + return res + + @staticmethod + def create_rand_matrix( + r: int = 1, + c: int = 1, + shape: Shape | None = None, + *args, + **kwargs, + ) -> Matrix: + r"""Creates a matrix with random entries. + + This method generates a matrix of size $r \times c$ where the + entries are real integers. The values in the matrix are generated using SymPy's + [`randMatrix`][sympy.matrices.dense.randMatrix] function. + + Note: + - The entries in the matrix are generated randomly and will change each time + the function is called. Setting a random seed using `seed` in `**kwargs` + will ensure reproducibility of the random values. + + Args: + r (int, optional): The number of rows in the matrix. + c (int, optional): The number of columns in the matrix. + shape (Shape, optional): If provided, the matrix will be reshaped to this + specific shape. Supported shapes include: + + - [DIAGONAL][(p).Shape.DIAGONAL]: Returns a diagonal matrix. + - [SCALAR][(p).Shape.SCALAR]: Returns a scalar matrix. + - [UPPER][(p).Shape.UPPER]: Returns an upper triangular matrix. + - [LOWER][(p).Shape.LOWER]: Returns a lower triangular matrix. + - [STRICT_UPPER][(p).Shape.STRICT_UPPER]: Returns an upper triangular matrix without the diagonal. + - [STRICT_LOWER][(p).Shape.STRICT_LOWER]: Returns a lower triangular matrix without the diagonal. + - [SYMMETRIC][(p).Shape.SYMMETRIC]: Returns a symmetric matrix. + *args: Additional arguments passed to the [`randMatrix`][sympy.matrices.dense.randMatrix] + function. + **kwargs: Additional arguments passed to the [`randMatrix`][sympy.matrices.dense.randMatrix] + function. + + Returns: + (Matrix): A Matrix with random entries of the specified size. + + Raises: + sympy.matrices.exceptions.NonSquareMatrixError: If `shape` is ill-defined on a non-square matrix. + + Examples: + >>> Matrix.create_rand_matrix(2, 3, seed=42) + Matrix([ + [81, 14, 3] + [94, 35, 31] + ]) + + See Also: + - [`create_unk_matrix`][..]: Creates a matrix with symbolic entries. + - [`randMatrix`][sympy.matrices.dense.randMatrix]: SymPy function to create a random matrix. + """ + res = Matrix(sym.randMatrix(*args, r=r, c=c, **kwargs)) + if shape: + return res._shape(shape) + else: + return res + + # Override + @staticmethod + def eye(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: + return Matrix(sym.eye(*args, **kwargs), aug_pos=aug_pos) + + # Override + @staticmethod + def zeros(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: + return Matrix(sym.zeros(*args, **kwargs), aug_pos=aug_pos) + + # Override + @staticmethod + def ones(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: + return Matrix(sym.ones(*args, **kwargs), aug_pos=aug_pos) + + # Override + @staticmethod + def diag(*args, aug_pos: Iterable[int] | int | None = None, **kwargs) -> Matrix: + return Matrix(sym.diag(*args, **kwargs), aug_pos=aug_pos) + + # Override + @property + def H(self): + """Returns the Hermitian transpose (conjugate transpose) of the matrix. + + This overrides the default behavior where .H calls .adjoint(), which in this + class is overridden to return the adjugate matrix. + """ + return self.T.conjugate() + + # Override + @property + def T(self) -> Matrix: + return Matrix(super().T) + + ################################################### + # OVERRIDE OVERLOADED PYTHON ARITHMETIC OPERATORS # + ################################################### + + def __abs__(self) -> Matrix: + aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() + return Matrix(super().__abs__(), aug_pos=aug) + + def __add__(self, other: Matrix) -> Matrix: + aug_self = getattr(self, "_aug_pos", set()) + aug_other = getattr(other, "_aug_pos", set()) + aug = aug_self | aug_other + return Matrix(super().__add__(other), aug_pos=aug) + + def __mul__(self, other) -> Matrix: + res = super().__mul__(other) + if hasattr(other, "shape") and self.shape != res.shape: + return Matrix(res) + else: + # probably scalar multiplication + aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() + return Matrix(res, aug_pos=aug) + + def __rmul__(self, other) -> Matrix: + res = super().__rmul__(other) + if hasattr(other, "shape") and self.shape != res.shape: + return Matrix(res) + else: + # probably scalar multiplication + aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() + return Matrix(res, aug_pos=aug) + + def __neg__(self) -> Matrix: + aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() + return Matrix(super().__neg__(), aug_pos=aug) + + ###################### + # BASIC MANIPULATORS # + ###################### + + # Override + def copy(self) -> Matrix: + """ + Creates a copy of the matrix, preserving augmentation lines. + + This method returns a new [`Matrix`][...] object that is a deep copy of the current matrix, + including any augmentation line positions (used for displaying augmented matrices). + + Returns: + (Matrix): A new matrix object with the same entries and augmentation lines as the original. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]], aug_pos={1}) + >>> mat_copy = mat.copy() + >>> mat_copy == mat + True + >>> mat_copy is mat + False + """ + new_mat = super().copy() + aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() + return Matrix(new_mat, aug_pos=aug) + + # Override + def subs(self, *args, **kwargs) -> Matrix: + """Substitutes values in the matrix entries. + + This method overrides SymPy's [`subs`][sympy.matrices.matrixbase.MatrixBase.subs] + method to ensure that the augmentation lines are preserved after substitution. + + Args: + *args: Positional arguments for substitution. + **kwargs: Keyword arguments for substitution. + + Returns: + (Matrix): A new matrix with substituted values, preserving augmentation lines. + + Examples: + >>> x = sym.Symbol('x') + >>> mat = Matrix([[x, 1]], aug_pos=0) + >>> mat.subs({x: 2}) == Matrix([[2, 1]], aug_pos=0) + True + """ + new_mat = super().subs(*args, **kwargs) + aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() + return Matrix(new_mat, aug_pos=aug) + + # Override + def simplify( + self, + rational: bool = True, + suppress_warnings: bool = False, + tolerance: float = 1e-4, + simplify: bool = True, + expand: bool = True, + collect_sym: Symbol | None = None, + *args, + **kwargs, + ) -> None: + """Simplifies the matrix by applying various simplification techniques. + + This method performs several operations on the matrix to simplify its entries: + - Rational simplification. + - General symbolic simplification. + - Expansion or factoring of expressions. + - Collecting terms involving a specific symbol (if provided). + + Note: + - Rational simplification attempts to convert entries into rational numbers if possible. + If there is a residue (e.g. attempting to convert a non-rational number into a rational), + a warning is printed with the approximation error. + - Expansion and factoring can be controlled by the `expand` parameter. + - The matrix is modified in place. + + Args: + rational (bool, optional): If True, applies rational simplification + to the matrix entries using [`sym.nsimplify`][sympy.simplify.simplify.nsimplify]. + suppress_warnings (bool, optional): If True, suppresses warnings about non-zero residues + after rational simplification. + tolerance (float, optional): The tolerance for rational simplification. + simplify (bool, optional): If True, applies general symbolic simplification using [`sym.simplify`][sympy.simplify.simplify.simplify]. + expand (bool, optional): If True, applies expansion to the matrix entries. If False, applies factoring instead. + collect_sym (Symbol, optional): A symbol to collect terms with. If provided, + [`sym.collect`][sympy.simplify.radsimp.collect] will be applied to all entries of the matrix with respect to this symbol. + *args: Additional arguments passed to the [`sym.simplify`][sympy.simplify.simplify.simplify] function. + **kwargs: Additional arguments passed to the [`sym.simplify`][sympy.simplify.simplify.simplify] function. + + Returns: + (Matrix): A new simplified matrix with the applied operations. + + Examples: + >>> mat = Matrix([[sym.symbols('x') + 1, sym.symbols('x') + 2], [sym.symbols('x') + 3, sym.symbols('x') + 4]]) + >>> mat.simplify(rational=False, expand=True) + >>> mat + Matrix([ + [x + 1, x + 2] + [x + 3, x + 4] + ]) + """ + + temp = self.copy() + if rational: + temp = sym.nsimplify(temp, tolerance=tolerance, rational=True) + residues = (temp - self).norm() + if residues != 0 and not suppress_warnings: + res = residues.evalf() + warn( + f""" + Non-zero Approximation Error: {res} + Rational approximation might have failed. Try lower tolerance.""", + RuntimeWarning, + stacklevel=2, + ) + if simplify: + temp = sym.simplify(temp, *args, **kwargs) + if expand: + temp = sym.expand(temp) + else: + temp = temp.applyfunc(lambda x: sym.factor(x)) + if collect_sym is not None: + temp = temp.applyfunc(lambda x: sym.collect(x, collect_sym)) + + # Create a new Matrix object from the simplified list and update the original object + aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() + temp = Matrix(temp, aug_pos=aug) # prevent SymPy from losing _aug_pos + self.__dict__.update(temp.__dict__) + + def identify( + self, tol: float | None = None, suppress_warnings: bool = False, *args, **kwargs + ) -> Matrix: + r"""Identifies the matrix by applying a transformation function to each entry. + + This method applies a transformation to each element of the matrix using + the [`identify`][mpmath.identify] function from the `mp` module. After identification, + the method checks if there is any residue (i.e., if the matrix has been modified). + + Note: + - If there is a residue (i.e., unable to identify an entry, such as $\pi$), + a warning is printed with the approximation error. This can be resolved by + lowering `tolerance` or supplying appropriate `constants` as `**kwargs` to + the `identify` function. + + Args: + tol (float, optional): A tolerance value that is passed to the + [`identify`][mpmath.identify] function. If None, no tolerance is applied. + suppress_warnings (bool, optional): If True, suppresses warnings about non-zero residues + after identification. + *args: Additional positional arguments passed to the + [`identify`][mpmath.identify] function. + **kwargs: Additional keyword arguments passed to the + [`identify`][mpmath.identify] function. + + Returns: + (Matrix): A new matrix that results from applying the transformation to + each element of the original matrix. + + Examples: + >>> import math + >>> import pytest + >>> mat = Matrix([[math.sqrt(4), math.e], [1/math.sqrt(2), 0.0]]) + >>> with pytest.warns(RuntimeWarning, match="Non-zero Identification Error"): + ... result = mat.identify() + >>> result == Matrix([[2, sym.E], [sym.sqrt(2) / 2, 0]]) + True + + See Also: + - [`mpmath.identify`][mpmath.identify]: The function used to identify + the entries of the matrix. + - [`simplify`][..simplify]: + For general simplification of the matrix entries. + """ + + temp = self.applyfunc(lambda x: mp.identify(x, tol=tol, *args, **kwargs)) + residues = (temp - self).norm() + if residues != 0 and not suppress_warnings: + res = residues.evalf() + warn(f"Non-zero Identification Error: {res}", RuntimeWarning, stacklevel=2) + return temp + + def select_cols(self, *args: int) -> Matrix: + """Selects columns from the matrix based on the provided column indices. + + This method returns a new matrix consisting of the columns specified by the + provided indices. The columns are selected from the original matrix, and the + result is returned as a new matrix. + + Args: + *args (int): One or more column indices (0-based) to select from the matrix. + + Returns: + (Matrix): A new matrix consisting of the selected columns. + + Examples: + >>> mat = Matrix([[1, 2, 3], [4, 5, 6]]) + >>> mat.select_cols(0, 2) + Matrix([ + [1, 3] + [4, 6] + ]) + """ + + res = [] + for idx in args: + res.append(list(self.col(idx))) + return Matrix(res).T + + def select_rows(self, *args: int) -> Matrix: + """Selects rows from the matrix based on the provided row indices. + + This method returns a new matrix consisting of the rows specified by the + provided indices. The rows are selected from the original matrix, and the + result is returned as a new matrix. + + Args: + *args (int): One or more row indices (0-based) to select from the matrix. + + Returns: + (Matrix): A new matrix consisting of the selected rows. + + Examples: + >>> mat = Matrix([[1, 2, 3], [4, 5, 6]]) + >>> mat.select_rows(0) + Matrix([[1, 2, 3] + ]) + """ + + res = [] + for idx in args: + res.append(list(self.row(idx))) + return Matrix(res) + + def sep_part_gen(self) -> PartGen: + """ + Separates a matrix into its particular and general solution parts. + + This method separates the matrix into two components: + - The **particular solution**, which is the solution to the system when + all free variables are set to zero. + - The **general solution**, which is the full solution including the + homogeneous part. + + It assumes that the matrix is in symbolic form and contains free variables that can be set to zero. + + Returns: + (PartGen): A dataclass containing two matrices: + + - `part_sol` ([`Matrix`][...]): The particular solution + (with free variables set to zero). + - `gen_sol` ([`Matrix`][...]): The general solution (the original matrix + minus the particular solution). + + Examples: + >>> from sympy import symbols + >>> x = symbols('x') + >>> mat = Matrix([[x + 2, 3], [3*x, x - 2]]) + >>> PG = mat.sep_part_gen() + >>> PG.part_sol # Particular solution + Matrix([ + [2, 3] + [0, -2] + ]) + >>> PG.gen_sol # General solution + Matrix([ + [ x, 0] + [3*x, x] + ]) + """ + + set_0 = dict(((symbol, 0) for symbol in self.free_symbols)) + part_sol = self.subs(set_0) + gen_sol = self - part_sol + return PartGen(part_sol, gen_sol) + + def sep_unk(self) -> dict[Expr, Matrix]: + """Separates the matrix into matrices with each free symbol set to 1. + + Returns: + (dict[Expr, Matrix]): Returns a dictionary where the sum of the key*value pairs + reconstructs the original matrix. Each key is a free symbol, and each value is a + matrix with that symbol set to 1 and all other free symbols set to 0. + + Examples: + >>> x, y = sym.symbols('x y') + >>> mat = Matrix([[x + y]]) + >>> parts = mat.sep_unk() + >>> parts[x] == Matrix([[1]]) + True + >>> parts[y] == Matrix([[1]]) + True + """ + syms = self.free_symbols + res: dict[Expr, Matrix] = defaultdict(Matrix) + for s in syms: + sub = {symbol: 0 for symbol in syms if symbol != s} + sub[s] = 1 # type: ignore + res[s] = self.subs(sub) + return res + + def scalar_factor(self, column: bool = True) -> ScalarFactor: + r"""Factorizes a matrix into the form $\mathbf{A} = \mathbf{FD}$, where $\mathbf{D}$ is a diagonal matrix + and $\mathbf{F}$ contains the vectors with common divisors factored out (if `column=True`). If `column=False`, + then returns $\mathbf{A} = \mathbf{DF}$ instead. + + Args: + column (bool): If `True`, factorizes by columns. If `False`, factorizes by rows. + + Returns: + (ScalarFactor): A dataclass of two matrices (F, D) and order (FD or DF) + + - `diag` ([`Matrix`][...]): The diagonal matrix containing the common divisors. + - `full` ([`Matrix`][...]): The matrix with common divisors factored out. + - `order` (str): The order of the factorization, either "FD" (for column factorization) or "DF" (for row factorization). + + Examples: + >>> mat = Matrix([[6, 9], [12, 15]]) + >>> SF = mat.scalar_factor(column=True) + >>> SF.full, SF.diag + (Matrix([ + [1, 3] + [2, 5] + ]), Matrix([ + [6, 0] + [0, 3] + ])) + """ + + def prettify(scalar: Expr) -> Expr: + num, den = sym.fraction(scalar) + if den == 1: + # return non-fractions as it is + return num + # for scalar in the form sqrt(x * k**2) / x, return k / sqrt(x) + # most notable case is sqrt(x) / x, which should return 1/sqrt(x) + k = sym.sqrt(num / sym.sqrt(den), evaluate=True) # type: ignore + if k.is_integer: # type: ignore + with sym.evaluate(False): + return k / sym.sqrt(den) # type: ignore + else: + return scalar + + scalars = [] + F = self.copy() + if column: + for i in range(self.cols): + g = sym.gcd(tuple(self.col(i))) + F[:, i] /= g + scalars.append(prettify(g)) + D = Matrix.diag(*scalars) + if not self.free_symbols: + assert self == (F @ D).doit(), "Matrix factorization failed." + return ScalarFactor(diag=D, full=F, order="FD") + else: + for i in range(self.rows): + g = sym.gcd(tuple(self.row(i))) + F[i, :] /= g + scalars.append(prettify(g)) + D = Matrix.diag(*scalars) + if not self.free_symbols: + assert self == (D @ F).doit(), "Matrix factorization failed." + return ScalarFactor(diag=D, full=F, order="DF") + + ############################# + # CHAPTER 1: LINEAR SYSTEMS # + ############################# + + def aug_line(self, pos: int = -1) -> Matrix: + """Inserts an augmented line at the specified position. + + This method adds an augmented line (i.e., a visual vertical line) + to the matrix at the specified column position. If no position is provided (default: -1), + the line is inserted at the last column. + + Note: + - The method updates the `_aug_pos` attribute to track the position of the inserted line. + - Negative `pos` will be converted before inserting it into `_aug_pos`. + + Args: + pos (int, optional): + The position (column index) where the augmented line will be inserted. + Default -1 means the augmented line is added at the end of the matrix. + + Returns: + (Matrix): The current matrix with the augmented line added at the specified position. + + Raises: + IndexError: If the `pos` is out of range. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.aug_line(0) + Matrix([ + [1 | 2] + [3 | 4] + ]) + + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.aug_line().row_join(mat) + Matrix([ + [1, 2 | 1, 2] + [3, 4 | 3, 4] + ]) + + See Also: + - [`rm_aug_line`][..]: Removes an augmentation line from the matrix. + """ + + new_pos = pos + if new_pos < 0: + new_pos += self.cols + + if not 0 <= new_pos < self.cols: + raise IndexError( + f"Position for augmented line ({pos}) out of range ({self.cols})." + ) + + if not hasattr(self, "_aug_pos"): + self._aug_pos: set[int] = set() + self._aug_pos.add(new_pos) + return self + + def rm_aug_line(self, pos: int | None = None) -> Matrix: + """Remove an augmentation line from the matrix. + + Removes the specified position from the matrix's augmentation line tracking. + If the matrix has no augmentation lines tracked or the position is not + currently marked as an augmentation line, the matrix remains unchanged. + + Note: + This method modifies the matrix's internal `_aug_pos` attribute which + tracks augmentation line positions. If no `_aug_pos` attribute exists, + it will be initialized as an empty set. + + Args: + pos (int, optional): The column position of the augmentation line to remove. + If it is not set, all augmentation lines will be removed + + Returns: + (Matrix): The matrix instance (supports method chaining). + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]], aug_pos=0) + >>> mat.rm_aug_line(0) + Matrix([ + [1, 2] + [3, 4] + ]) + """ + if not hasattr(self, "_aug_pos") or pos is None: + self._aug_pos = set() + return self + if pos in self._aug_pos: + self._aug_pos.remove(pos) + return self + + # Override + def row_join(self, other: Matrix, aug_line: bool = True) -> Matrix: + """Joins two matrices horizontally (column-wise), preserving augmentation lines. + + This method concatenates the columns of `self` and `other` to form a new matrix. Any augmentation lines + (vertical lines for augmented matrices) tracked in either matrix are preserved and adjusted for the new column positions. + + Args: + other (Matrix): The matrix to join to the right of `self`. + aug_line (bool, optional): If `True`, adds an augmentation line between the two matrices. + + Returns: + (Matrix): A new matrix formed by joining `self` and `other` column-wise, with updated augmentation lines. + + Raises: + sympy.matrices.exceptions.ShapeError: If the number of rows in `self` and `other` do not match. + + Examples: + >>> A = Matrix([[1, 2], [3, 4]]) + >>> B = Matrix([[5], [6]]) + >>> A.row_join(B) + Matrix([ + [1, 2 | 5] + [3, 4 | 6] + ]) + """ + aug = set(getattr(self, "_aug_pos", set())) + other_aug = set(getattr(other, "_aug_pos", set())) + offset = self.cols + for pos in other_aug: + aug.add(pos + offset) + if aug_line: + aug.add(offset - 1) + return Matrix(super().row_join(other), aug_pos=aug) + + # Override + def col_join(self, other: Matrix) -> Matrix: + """ + Joins two matrices vertically (row-wise), preserving augmentation lines. + + This method concatenates the rows of `self` and `other` to form a new matrix. Only augmentation lines + (vertical lines for augmented matrices) that are present in both matrices at the same column positions are preserved. + + Note: + - Both `self` and `other` matrices should have the same number of columns for the join to be valid. + - The method updates the `_aug_pos` attribute to include only those positions that are common + in both matrices, ensuring that the augmentation lines are correctly aligned after the join. + + Args: + other (Matrix): The matrix to join below `self`. + + Returns: + (Matrix): A new matrix formed by joining `self` and `other` row-wise, with preserved augmentation lines. + + Raises: + sympy.matrices.exceptions.ShapeError: If the number of columns in `self` and `other` do not match. + + Examples: + >>> A = Matrix([[1, 2]], aug_pos={0}) + >>> B = Matrix([[3, 4]], aug_pos={0}) + >>> A.col_join(B) + Matrix([ + [1 | 2] + [3 | 4] + ]) + """ + aug_self = set(getattr(self, "_aug_pos", set())) + aug_other = set(getattr(other, "_aug_pos", set())) + aug = aug_self & aug_other + return Matrix(super().col_join(other), aug_pos=aug) + + def scale_row( + self, idx: int, scalar: Expr | float | int, verbosity: int = 2 + ) -> Matrix: + """ + Scales a row of the matrix by a scalar and simplifies the result. + + This method scales a specified row of the matrix by multiplying it with a scalar + and then simplifies the matrix. The result is stored back in the matrix. Optionally, + the method can print information about the row scaling and display the matrix, + depending on the verbosity level. + + Note: + - The method modifies the matrix in-place and returns the updated matrix. + - After scaling the row, the matrix is simplified using [`simplify`][..]. + + Args: + idx (int): The index of the row to scale (0-based). + scalar (Expr, float, int): The scalar by which to multiply the row. + verbosity (int, optional): The level of verbosity for output. + + - 0: No output. + - 1: Print the row scaling operation. + - 2: Print the row scaling operation and display the matrix. + + Returns: + (Matrix): The modified matrix with the scaled row. + + Raises: + IndexError: If the `idx` is out of range for the number of rows in the matrix. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.scale_row(0, 2, verbosity=0) + Matrix([ + [2, 4] + [3, 4] + ]) + """ + + if scalar == 0: + warn("Matrix rows should not be scaled by 0", UserWarning, stacklevel=2) + + scalar = sym.sympify(scalar) + self[idx, :] *= scalar # type: ignore + self.simplify(suppress_warnings=True) + + if verbosity >= 1: + display( + f"\\left({sym.latex(scalar)}\\right) R_{idx + 1} \\rightarrow R_{idx + 1}", + opt="math", + ) + if verbosity >= 2: + display(self) + print("\n") + + return self + + def swap_row(self, idx_1: int, idx_2: int, verbosity: int = 2) -> Matrix: + """Swaps two rows of the matrix. + + This method swaps the contents of two rows in the matrix. The operation is performed + in-place, and the modified matrix is returned. Optionally, the method can print + information about the row swap and display the matrix, depending on the verbosity level. + + Note: + - The method modifies the matrix in-place and returns the updated matrix. + - After performing the row swaps, the matrix is simplified using [`simplify`][..]. + + Args: + idx_1 (int): The index of the first row to swap (0-based). + idx_2 (int): The index of the second row to swap (0-based). + verbosity (int, optional): The level of verbosity for output. + + - 0: No output. + - 1: Print the row swap operation. + - 2: Print the row swap operation and display the matrix. + + Returns: + (Matrix): The modified matrix after the row swap. + + Raises: + IndexError: If the `idx_1` or `idx_2` is out of range for the number of rows in the matrix. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.swap_row(0, 1, verbosity=0) + Matrix([ + [3, 4] + [1, 2] + ]) + """ + + self[idx_1, :], self[idx_2, :] = self[idx_2, :], self[idx_1, :] + + if verbosity >= 1: + display(f"R_{idx_1 + 1} \\leftrightarrow R_{idx_2 + 1}", opt="math") + if verbosity >= 2: + display(self) + print("\n") + + return self + + def reduce_row( + self, idx_1: int, scalar: Expr | float | int, idx_2: int, verbosity: int = 2 + ) -> Matrix: + """Reduces a row by subtracting a scalar multiple of another row. + + This method modifies a row by subtracting a specified scalar multiple of another row. + The result is stored back in the matrix. Optionally, the method can print information + about the row reduction and display the matrix, depending on the verbosity level. + + Note: + - The method modifies the matrix in-place and returns the updated matrix. + - After performing the row reduction, the matrix is simplified using [`simplify`][..]. + + Args: + idx_1 (int): The index of the row to reduce (0-based). + scalar (Expr, float, int): The scalar by which to multiply the second row. + idx_2 (int): The index of the row from which to subtract the scalar multiple (0-based). + verbosity (int, optional): The level of verbosity for output. + + - 0: No output. + - 1: Print the row reduction operation. + - 2: Print the row reduction operation and display the matrix. + + Returns: + (Matrix): The modified matrix after the row reduction. + + Raises: + IndexError: If the `idx_1` or `idx_2` is out of range for the number of rows in the matrix. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.reduce_row(0, 2, 1, verbosity=0) + Matrix([ + [-5, -6] + [ 3, 4] + ]) + """ + + scalar = sym.sympify(scalar) + self[idx_1, :] = self[idx_1, :] - scalar * self[idx_2, :] # type: ignore + self.simplify(suppress_warnings=True) + + if verbosity >= 1: + display( + f"R_{idx_1 + 1} - \\left({sym.latex(scalar)}\\right)R_{idx_2 + 1} \\rightarrow R_{idx_1 + 1}", + opt="math", + ) + if verbosity >= 2: + display(self) + print("\n") + + return self + + def get_pivot_row( + self, col_idx: int, row_start_idx: int, follow_GE: bool = False + ) -> int | None: + """Finds the row index of the pivot element in a given column. + + This method attempts to find a row that contains a non-zero element in the + specified column. If the `follow_GE` flag is `False`, it first looks for + a non-zero constant that does not contain any symbolic expressions. If no + such element is found, it will return the first non-zero element. If the + entire column contains only zeros, the method returns -1. + + Args: + col_idx (int): The index of the column to search for the pivot. + row_start_idx (int): The row index to start searching from. + follow_GE (bool, optional): Flag to control whether to follow Gaussian elimination strategy. + + - `True`: Always return the first non-zero element, even if it is symbolic. + - `False`: First look for non-zero constants that are not symbolic expressions. + + Returns: + (int): The index of the row containing the pivot element, or None if no pivot is found. + + Examples: + >>> mat = Matrix([[1, 2, 3], [4, 5, 6], [0, 0, 0]]) + >>> mat.get_pivot_row(0, 0) + 0 + """ + + # Step 1: Search for a non-zero constant that is not symbolic (if not following Gaussian elimination) + # that it is easier to reduce other rows + if not follow_GE: + for row_idx in range(row_start_idx, self.rows): + term = self[row_idx, col_idx] + if term != 0: + # Check if it's not a symbolic expression + if not isinstance(term, Expr): + return row_idx + # Check if it's a non-symbolic constant + elif len(term.free_symbols) == 0: + return row_idx + + # Step 2: If no non-zero constant is found, return the first non-zero element (symbolic or not) + for row_idx in range(row_start_idx, self.rows): + term = self[row_idx, col_idx] + if term != 0: + return row_idx + + # Step 3: If no non-zero element is found, return None (indicating no pivot) + return None + + def get_pivot_pos(self) -> list[tuple[int, int]]: + """Finds the positions of the pivot elements in the matrix. + + This method checks the matrix to determine the positions of the pivots + (the first non-zero entry in each row) by examining each column one-by-one. + It assumes that the matrix is in Row Echelon Form (REF), as checked by the + [`is_echelon`][sympy.matrices.matrixbase.MatrixBase.is_echelon] property. + + It uses [`get_pivot_row`][..] to find the pivot row for each column. + For each pivot found, a tuple (row, column) is added to the result list. + + Returns: + (list[tuple[int, int]]): A list of lists, where each sublist contains a + tuple representing the position (row, column) of a pivot. + + Examples: + >>> mat = Matrix([[1, 2, 3], [0, 0, 5], [0, 0, 0]]) + >>> mat.get_pivot_pos() + [(0, 0), (1, 2)] + """ + + assert self.is_echelon # check for REF + + pivot_pos: list[tuple[int, int]] = [] + cur_row_pos = 0 + for cur_col_pos in range(self.cols): + pivot_row = self.get_pivot_row(cur_col_pos, cur_row_pos, follow_GE=False) + + if pivot_row is not None: + pivot_pos.append((pivot_row, cur_col_pos)) + cur_row_pos += 1 + + return pivot_pos + + def get_pivot_elements(self) -> list[sym.Expr]: + """Retrieves the pivot elements from the matrix. + + This method identifies the pivot positions (row, column) using the + [`get_pivot_pos`][..] method and + then extracts the elements at those positions in the matrix. + + Returns: + (list[Expr]): A list of pivot elements corresponding + to the positions identified by [`get_pivot_pos`][..]. + + Examples: + >>> mat = Matrix([[1, 2, 3], [0, 0, 5], [0, 0, 0]]) + >>> mat.get_pivot_elements() + [1, 5] + """ + + pivot_elements: list[sym.Expr] = [] + + for i, j in self.get_pivot_pos(): + pivot_elements.append(self[i, j]) # type: ignore + + return pivot_elements + + def _symbolic_reduce_row( + self, + row_idx: int, + pivot_row_idx: int, + col_idx: int, + max_tries: int = 2, + verbosity: int = 0, + L_matrix: Matrix | None = None, + ) -> None: + """Reduce a row using a pivot row, handling symbolic denominators. + + Used by both [`ref`][..] and [`rref_cases`][..]. If `L_matrix` is provided, + it records the operations to maintain the `L` matrix in PLU decomposition. + + Args: + row_idx (int): The index of the row to reduce. + pivot_row_idx (int): The index of the pivot row to use for reduction. + col_idx (int): The index of the column to reduce. + max_tries (int, optional): Maximum number of tries to reduce a row in case of symbolic denominators. + verbosity (int, optional): The level of verbosity for output. + - 0: No output. + - 1: Print the row reduction operation. + - 2: Print the row reduction operation and display the matrix. + L_matrix ([`Matrix`][...], optional): If provided, updates this matrix to reflect the row operations + for maintaining the L matrix in PLU decomposition. + """ + Identity = self.elem() + tries = 0 + while sym.simplify(self[row_idx, col_idx]) != 0: + tries += 1 + if tries > max_tries: + warn( + f"ERROR: Max tries exceeded to reduce row {row_idx + 1} with row {pivot_row_idx + 1}", + RuntimeWarning, + stacklevel=2, + ) + break + try: + scalar = self[row_idx, col_idx] / self[pivot_row_idx, col_idx] # type: ignore + scalar = scalar.expand().simplify() + + try: + decomp = sym.apart(scalar) # partial fractions + except Exception: + decomp = scalar + if isinstance(decomp, sym.Add): + terms = decomp.args + else: + terms = [decomp] + + for term in terms: + _, d = sym.fraction(term) + if sym.simplify(d) != 0: + self.reduce_row( + row_idx, term, pivot_row_idx, verbosity=verbosity + ) + if L_matrix is not None: + elem = Identity.copy().reduce_row( + row_idx, -term, pivot_row_idx, verbosity=0 + ) + L_matrix.__dict__.update((L_matrix @ elem).__dict__) + + # Scaling fallback for symbolic denominators + if sym.simplify(self[row_idx, col_idx]) != 0: + scalar = self[pivot_row_idx, col_idx] / self[row_idx, col_idx] # type: ignore + tmp = scalar.simplify() + if tmp is not None: + scalar = tmp + n, d = sym.fraction(scalar) + if (sym.simplify(n) != 0) and (sym.simplify(d) != 0): + self.scale_row(row_idx, scalar, verbosity=verbosity) + if L_matrix is not None: + elem = Identity.copy().scale_row( + row_idx, sym.Integer(1) / scalar, verbosity=0 + ) + L_matrix.__dict__.update((L_matrix @ elem).__dict__) + except Exception as error: + warn(f"Reduction error: {error}", RuntimeWarning) + break + + def ref( + self, + verbosity: int = 2, + max_tries: int = 2, + follow_GE: bool = False, + ) -> PLU: + """Find the Row Echelon Form (REF) of the matrix. + + This method applies Gaussian elimination (or a similar approach) to bring + the matrix to row echelon form. + + Note: + - PLU decomposition is the generalisation of the LU decomposition. Unlike + LU decomposition, PLU works for any matrix. + - The REF is obtained from `PLU.U` and the matrix is LU factorisable if `PLU.P` + is the identity matrix. + + Args: + verbosity (int, optional): Level of verbosity for the output. + + - 0: No output. + - 1: Output basic information (e.g., row operations). + - 2: Output detailed information (e.g., matrix states after each operation). + max_tries (int, optional): Maximum number of tries to reduce a row in case of symbolic denominators. + follow_GE (bool, optional): Whether to strictly follow Gaussian elimination rules. + + - `True`: Always return the first non-zero element, even if it is symbolic. + - `False`: First look for non-zero constants that are not symbolic expressions. + + Returns: + (PLU): A dataclass containing the following matrices: + + - `P` ([`Matrix`][...]): The permutation matrix used during the transformation. + - `L` ([`Matrix`][...]): The lower triangular matrix representing the multipliers used in the elimination process. + - `U` ([`Matrix`][...]): The upper triangular matrix in row echelon form. + + Examples: + >>> mat = Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) + >>> mat.ref(verbosity=0) + PLU(P=Matrix([ + [1, 0, 0] + [0, 1, 0] + [0, 0, 1] + ]), L=Matrix([ + [1, 0, 0] + [4, 1, 0] + [7, 2, 1] + ]), U=Matrix([ + [1, 2, 3] + [0, -3, -6] + [0, 0, 0] + ])) + + See Also: + - [`Matrix.LUdecomposition`][sympy.matrices.matrixbase.MatrixBase.LUdecomposition] + """ + + U = self.copy() + + Identity = self.elem() + L = self.elem() + P = self.elem() + + # Loop over each column + cur_row_pos = 0 + + for cur_col_pos in range(self.cols): + # Find the first non-zero row in the current column + pivot_row = U.get_pivot_row(cur_col_pos, cur_row_pos, follow_GE) + + if pivot_row is None: + # If no non-zero pivot is found, continue to the next column + continue + + # Swap the current row with the pivot row if necessary + if pivot_row != cur_row_pos: + U.swap_row(cur_row_pos, pivot_row, verbosity=verbosity) + P_elem = Identity.copy().swap_row(cur_row_pos, pivot_row, verbosity=0) + P = P @ P_elem + L = P_elem @ L @ P_elem + + # Eliminate the current column in rest of the rows below + for row_idx in range(cur_row_pos + 1, self.rows): + U._symbolic_reduce_row( + row_idx, cur_row_pos, cur_col_pos, max_tries, verbosity, L + ) + + cur_row_pos += 1 + + # Return the appropriate number of matrices based on the `matrices` parameter + return PLU(P, L, U) + + @staticmethod + def _case_item_key(item: tuple) -> tuple[str, str]: + """Return a canonical, orderable key for a symbolic (key, value) pair.""" + k, v = item + return (sym.srepr(sym.sympify(k)), sym.srepr(sym.sympify(v))) + + @classmethod + def _case_outcome_key(cls, case: RREFCase, rhs: Matrix | None) -> tuple[str, int]: + """Group key used by evaluate_cases for merge-by-outcome.""" + if rhs is None: + return ("hom", case.free_params) + if case.is_consistent is False: + return ("non", -1) + if case.free_params == 0: + return ("unique", 0) + return ("param", case.free_params) + + @classmethod + def _case_sort_key( + cls, case: RREFCase, rhs: Matrix | None + ) -> tuple[int, int, int, tuple]: + """Presentation order key for merged evaluate_cases output.""" + kind, param = cls._case_outcome_key(case, rhs) + priority = 2 + if kind == "non": + priority = 0 + elif kind == "unique": + priority = 1 + return ( + priority, + max(param, 0), + len(case.conditions), + tuple(sorted(cls._case_item_key(it) for it in case.conditions.items())), + ) + + @classmethod + def _dedupe_key(cls, case: RREFCase) -> tuple: + """Exact signature used for evaluate_cases de-duplication.""" + excluded_keys = tuple( + sorted(cls._case_item_key(tuple(d.items())[0]) for d in case.excluded) + ) + condition_keys = tuple( + sorted(cls._case_item_key(it) for it in case.conditions.items()) + ) + return ( + condition_keys, + excluded_keys, + case.is_consistent, + case.free_params, + case.pivots, + ) + + @classmethod + def _merge_case_group(cls, group_cases: list[RREFCase]) -> list[RREFCase]: + """Apply group-local absorption, redundancy removal, and dedupe.""" + # If a general case excludes a single assignment that is also + # represented by a specific sibling with the same outcome, absorb + # that assignment into the general case by removing the exclusion. + changed = True + while changed: + changed = False + for general in group_cases: + gen_items = set(general.conditions.items()) + gen_excluded = {tuple(d.items())[0] for d in general.excluded} + for specific in group_cases: + if general is specific: + continue + spec_items = set(specific.conditions.items()) + if not gen_items.issubset(spec_items): + continue + extra_items = spec_items - gen_items + if len(extra_items) != 1: + continue + extra_item = next(iter(extra_items)) + if extra_item not in gen_excluded: + continue + + general.excluded = [ + d for d in general.excluded if tuple(d.items())[0] != extra_item + ] + changed = True + + # Conservative merge: drop a specific case only if there is a less- + # specific case with the same outcome that already covers the + # dropped assignment(s). + kept: list[RREFCase] = [] + for candidate in group_cases: + cand_items = set(candidate.conditions.items()) + is_redundant = False + for general in group_cases: + if general is candidate: + continue + gen_items = set(general.conditions.items()) + if not gen_items.issubset(cand_items): + continue + dropped_items = cand_items - gen_items + if not dropped_items: + continue + + general_excluded = {tuple(d.items())[0] for d in general.excluded} + if all(item not in general_excluded for item in dropped_items): + is_redundant = True + break + + if not is_redundant: + kept.append(candidate) + + # Remove exact duplicates (same condition/outcome) while preserving order. + seen: set[tuple] = set() + deduped: list[RREFCase] = [] + for case in sorted( + kept, + key=lambda c: ( + len(c.conditions), + tuple(sorted(cls._case_item_key(it) for it in c.conditions.items())), + tuple( + sorted(cls._case_item_key(tuple(d.items())[0]) for d in c.excluded) + ), + ), + ): + key = cls._dedupe_key(case) + if key in seen: + continue + seen.add(key) + deduped.append(case) + + return deduped + + def evaluate_cases( + self, rhs: Matrix | None = None, verbosity: int = 0 + ) -> list[RREFCase]: + """Evaluates and displays all possible cases for solutions to a linear system involving the matrix. + + This method uses [`rref_cases`][..] to find symbolic cases, and group them + based on the system's outcome (no solution, unique solution, infinitely many solutions). + + 1. Merge redundant cases with identical outcomes only when a less- + specific case explicitly excludes the dropped assignment(s). + 2. Order results by outcome priority: + - No solution + - Unique solution + - Infinitely many solutions (increasing free parameters) + + Args: + rhs (Matrix, optional): The right-hand side of the system Ax = rhs. + If not provided, the system is treated as homogeneous. + verbosity (int, optional): The level of verbosity for the computation. + + Returns: + (list[RREFCase]): The merged and ordered list of symbolic RREF cases found by + [`rref_cases`][..]. + + Examples: + >>> a = sym.Symbol('a') + >>> A = Matrix([[a, 1], [0, 1]]) + >>> b = Matrix([[2], [3]]) + >>> cases = A.evaluate_cases(rhs=b, verbosity=0) + Summary of merged cases for non-homogeneous system: + Case 1: assume {a: 0}, excluding [] + No solution + Case 2: assume {}, excluding [{a: 0}] + Unique solution + + See Also: + - [`rref_cases`][..]: Returns case data without printing a summary or merging similar cases. + """ + cases = self.rref_cases(rhs=rhs, verbosity=verbosity) + + grouped: dict[tuple[str, int], list[RREFCase]] = defaultdict(list) + for case in cases: + grouped[self._case_outcome_key(case, rhs)].append(case) + + merged_cases: list[RREFCase] = [] + for _, group_cases in grouped.items(): + merged_cases.extend(self._merge_case_group(group_cases)) + + ordered_cases = sorted(merged_cases, key=lambda c: self._case_sort_key(c, rhs)) + + print( + f"Summary of merged cases for {'homogeneous' if rhs is None else 'non-homogeneous'} system:" + ) + for i, c in enumerate(ordered_cases, 1): + print(f"Case {i}: assume {c.conditions}, excluding {c.excluded}") + if rhs is None: + print(f"Homogeneous system with {c.free_params} free parameter(s)") + else: + if c.is_consistent: + if c.free_params == 0: + print("Unique solution") + else: + print(f"Solution with {c.free_params} free parameter(s)") + else: + print("No solution") + + if verbosity >= 1: + display(RREF(c.rref, c.pivots)) + print("\n") + + return ordered_cases + + # Override + def rref(self, *args, pivots: bool = True, **kwargs) -> RREF | Matrix: + """Computes the Reduced Row Echelon Form (RREF) of the matrix. + + This method is a wrapper for [`rref`][sympy.matrices.matrixbase.MatrixBase.rref] method + and returns the matrix in Reduced Row Echelon Form (RREF) along with the pivot positions. + + Args: + pivots (bool, optional): + If `True`, returns a tuple containing the RREF matrix and a list of pivot columns + *args: Positional arguments passed to SymPy's [`rref`][sympy.matrices.matrixbase.MatrixBase.rref] method. + **kwargs: Keyword arguments passed to SymPy's [`rref`][sympy.matrices.matrixbase.MatrixBase.rref] method. + + Returns: + (RREF): A dataclass containing the following: + + - `rref` ([`Matrix`][...]): The matrix in Reduced Row Echelon Form. + - `pivots` ([`list`][list][[`int`][int]]): A list of integers representing the indices of the pivot columns. + (Matrix): If `pivots` is `False`, only the `rref` will be returned. + + Examples: + >>> mat = Matrix([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) + >>> mat.rref() + RREF(rref=Matrix([ + [1, 0, -1] + [0, 1, 2] + [0, 0, 0] + ]), pivots=(0, 1)) + """ + if pivots: + rref_mat, pivot_pos = super().rref(*args, **kwargs) + else: + rref_mat = super().rref(*args, pivots=False, **kwargs) + + aug = self._aug_pos.copy() if hasattr(self, "_aug_pos") else set() + rref_mat = Matrix(rref_mat, aug_pos=aug) + + if pivots: + return RREF(rref_mat, pivot_pos) + else: + return rref_mat + + def _get_rref_pivots(self) -> list[int]: + """Return pivot column indices by scanning each row of an RREF matrix. + + For each row we take the leftmost non-zero entry as the pivot. Rows that + are entirely zero (zero rows) contribute no pivot. + """ + pivots: list[int] = [] + for row in range(self.rows): + for col in range(self.cols): + entry = sym.simplify(self[row, col]) + if entry != 0: + pivots.append(col) + break + return pivots + + def _check_rref_consistency(self) -> bool: + """Return `True` if the augmented RREF matrix represents a consistent system. + + A system is inconsistent when any row has all-zero entries on the left-hand side (LHS) of the + augmentation line(s) but a non-zero entry on the right-hand side (RHS). + """ + aug_pos = getattr(self, "_aug_pos", set()) + if not aug_pos: + raise ValueError( + "Matrix must have augmentation lines defined for consistency check." + ) + # Pick the leftmost augmentation line as the boundary between LHS and RHS + n_var_cols = min(aug_pos) + 1 + for row in range(self.rows): + lhs_zero = all( + sym.simplify(self[row, col]) == 0 for col in range(n_var_cols) + ) + rhs_nonzero = any( + sym.simplify(self[row, col]) != 0 + for col in range(n_var_cols, self.cols) + ) + if lhs_zero and rhs_nonzero: + return False + return True + + def _pivot_and_continue( + self, + conditions: dict, + cur_row: int, + cur_col: int, + pivot_row: int, + nonzero_assumptions: tuple[sym.Expr, ...] = (), + verbosity: int = 0, + ) -> "list[tuple[Matrix, dict]]": + """Swap the pivot into place, normalise the pivot row to 1, eliminate the + pivot column in every other row (full RREF), then recurse. + """ + # Create a copy to avoid mutating shared state across branches. + m = self.copy() + + # swap pivot row into position + if pivot_row != cur_row: + m.swap_row(cur_row, pivot_row, verbosity=verbosity) + + # normalise pivot to 1 (if not already 1) + pivot_val = sym.simplify(m[cur_row, cur_col]) + if pivot_val != 1: + m.scale_row(cur_row, sym.Integer(1) / pivot_val, verbosity=verbosity) + + # eliminate pivot column in all other rows (full RREF) + for row_idx in range(m.rows): + if row_idx == cur_row: + continue + m._symbolic_reduce_row(row_idx, cur_row, cur_col, verbosity=verbosity) + + return m._symbolic_rref( + conditions, + cur_row + 1, + cur_col + 1, + nonzero_assumptions=nonzero_assumptions, + verbosity=verbosity, + ) + + def _symbolic_rref( + self, + conditions: dict, + cur_row: int, + cur_col: int, + nonzero_assumptions: tuple[sym.Expr, ...] = (), + verbosity: int = 0, + ) -> "list[tuple[Matrix, dict]]": + """Recursively compute RREF, branching whenever a pivot entry has free + symbols that could be zero under some assignment. + """ + + # Apply current conditions and simplify + mat_eval = self.subs(conditions) + if not isinstance(mat_eval, Matrix): + mat_eval = Matrix(mat_eval) + mat_eval.simplify(rational=False, simplify=True, suppress_warnings=True) + + # Propagate prior "pivot != 0" assumptions under current conditions. + # If any such assumption simplifies to 0, this branch is contradictory. + next_nonzero: list[sym.Expr] = [] + for expr in nonzero_assumptions: + expr_eval = sym.simplify(expr.subs(conditions)) + if expr_eval == 0: + return [] + if expr_eval.free_symbols: + next_nonzero.append(expr_eval) + nonzero_assumptions = tuple(next_nonzero) + + # Base case: all columns (or rows) exhausted + if cur_col >= mat_eval.cols or cur_row >= mat_eval.rows: + if verbosity >= 1: + print("Completed branch with conditions:") + display(conditions) + if verbosity >= 2: + display(mat_eval) + print("\n") + return [(mat_eval, conditions)] + + # Find first non-zero entry in cur_col at or below cur_row + # Prefer constants to symbols (following GE logic) to avoid redundant branches + pivot_row = mat_eval.get_pivot_row(cur_col, cur_row, follow_GE=False) + + if pivot_row is None: + # No pivot in this column — skip to the next column. + return mat_eval._symbolic_rref( + conditions=conditions, + cur_row=cur_row, + cur_col=cur_col + 1, + nonzero_assumptions=nonzero_assumptions, + verbosity=verbosity, + ) + + pivot_entry = sym.simplify(mat_eval[pivot_row, cur_col]) + free_syms = pivot_entry.free_symbols + + if not free_syms: + # Pivot is a non-zero constant — proceed unconditionally. + return mat_eval._pivot_and_continue( + conditions=conditions, + cur_row=cur_row, + cur_col=cur_col, + pivot_row=pivot_row, + nonzero_assumptions=nonzero_assumptions, + verbosity=verbosity, + ) + + # Pivot has free symbols: check whether it can equal zero + try: + zero_solutions: list[dict] = sym.solve( + pivot_entry, list(free_syms), dict=True + ) + except Exception: + zero_solutions = [] + + if not zero_solutions: + # Cannot be zero — proceed unconditionally. + return mat_eval._pivot_and_continue( + conditions=conditions, + cur_row=cur_row, + cur_col=cur_col, + pivot_row=pivot_row, + nonzero_assumptions=nonzero_assumptions, + verbosity=verbosity, + ) + + # Branch: one branch per zero solution, plus the "non-zero" branch + results: list[tuple[Matrix, dict]] = [] + + if verbosity >= 1: + print( + f"Branching on pivot at ({pivot_row}, {cur_col}) due to possibility of zero value with free symbols {free_syms}" + ) + + branch_count = 1 + for zero_sol in zero_solutions: + # Skip degenerate solutions where no symbol is actually constrained. + if not zero_sol: + continue + + new_conds = {**conditions, **zero_sol} + + # Check if this branch contradicts a previous non-zero assumption + # by checking if substituting the zero solution produces division by zero. + test_mat = mat_eval.subs(zero_sol) + if test_mat.has(sym.zoo, sym.nan): + continue + + # Contradiction check with prior assumptions of the form expr != 0. + if any( + sym.simplify(expr.subs(zero_sol)) == 0 for expr in nonzero_assumptions + ): + continue + + # With this substitution the pivot becomes 0 — re-enter the same + # (cur_row, cur_col) so the algorithm searches for a different pivot. + if verbosity >= 1: + print( + f"Branch {branch_count} on pivot ({pivot_row}, {cur_col}) = 0 with conditions:" + ) + display(new_conds) + branch_count += 1 + results.extend( + mat_eval._symbolic_rref( + conditions=new_conds, + cur_row=cur_row, + cur_col=cur_col, + nonzero_assumptions=nonzero_assumptions, + verbosity=verbosity, + ) + ) + + # "Non-zero" branch: proceed with the current pivot as-is (symbolic but ≠ 0). + if verbosity >= 1: + print( + f"Branch {branch_count} on pivot ({pivot_row}, {cur_col}) ≠ 0 with conditions:" + ) + display(conditions) + results.extend( + mat_eval._pivot_and_continue( + conditions=conditions, + cur_row=cur_row, + cur_col=cur_col, + pivot_row=pivot_row, + nonzero_assumptions=nonzero_assumptions + (pivot_entry,), + verbosity=verbosity, + ) + ) + + return results + + def rref_cases( + self, rhs: Matrix | None = None, verbosity: int = 0 + ) -> list[RREFCase]: + """Compute all symbolic-RREF cases by splitting on zero-pivot conditions. + + When the matrix contains free symbols, different assignments of those + symbols can lead to structurally different row-echelon forms (different + numbers of pivots, inconsistencies, etc.). This method detects exactly + those critical values by branching at every pivot that could be zero, + and returns one [`RREFCase`][(p).RREFCase] per distinct branch. + + Algorithm: + + 1. Work column-by-column to find the leftmost pivot in each active row. + 2. If the candidate pivot entry has free symbols that can equal zero, + create two branches: + - **Zero branch**: substitute the zero-making values and retry the + same column (a different row may now become the pivot). + - **Non-zero branch**: treat the entry as a non-zero (possibly + symbolic) scalar, normalise the pivot row to 1, and eliminate + the pivot column in all other rows (full RREF). + 3. Recursion terminates when all columns (or rows) have been processed. + + Args: + rhs (Matrix, optional): Right-hand side of the system ``Ax = rhs``, + appended as an augmented column block. When provided, each + [`RREFCase`][(p).RREFCase] reports consistency in + `RREFCase.is_consistent`. + verbosity (int, optional): Level of verbosity for the computation. + + Returns: + (list[RREFCase]): One entry per distinct case. Each + [`RREFCase`][(p).RREFCase] contains: + + - ``conditions`` — the symbol substitutions that define the case. + - ``excluded`` — zero-conditions from *other* cases (i.e. what + is **not** assumed here), excluding redundant alternatives for + symbols already fixed by ``conditions``. + - ``rref`` — the RREF matrix (augmented if *rhs* was given). + - ``pivots`` — pivot column indices. + - ``free_params`` — number of free parameters. + - ``is_consistent`` — ``True``/``False`` (``None`` if no *rhs*). + + Examples: + Pure homogeneous system with one parameter: + + >>> import sympy as sym + >>> a = sym.Symbol('a') + >>> A = Matrix([[a, 1], [0, 1]]) + >>> cases = A.rref_cases() + >>> for c in cases: + ... print(c.conditions, '|', c.free_params, 'free params') + {a: 0} | 1 free params + {} | 0 free params + + Non-homogeneous system also checks consistency: + + >>> b_vec = Matrix([[2], [3]]) + >>> cases = A.rref_cases(rhs=b_vec) + >>> for c in cases: + ... print(c.conditions, '| consistent:', c.is_consistent) + {a: 0} | consistent: False + {} | consistent: True + + See Also: + - [`rref`][..] for the standard (non-branching) RREF wrapper. + - [`evaluate_cases`][..] for a printing-oriented case analysis. + """ + # Build working matrix (augmented when rhs is supplied). + if rhs is not None: + working_mat: Matrix = self.row_join(rhs, aug_line=True) + else: + working_mat = self.copy() + + n_var_cols = self.cols + # Run the recursive symbolic RREF, collecting all branches. + raw_results: list[tuple[Matrix, dict]] = working_mat._symbolic_rref( + conditions={}, cur_row=0, cur_col=0, verbosity=verbosity + ) + + # Collect all zero-conditions discovered across every branch so we can + # compute the "excluded" set for each case. + all_zero_conds: set[tuple] = set() + for _, conds in raw_results: + for item in conds.items(): + all_zero_conds.add(item) + + result_cases: list[RREFCase] = [] + for mat, conds in raw_results: + # excluded = zero-conditions present in other branches but not here. + # If this branch already fixes a symbol (e.g. a = 0), skip other + # alternatives for that same symbol (e.g. a = 1), because they are + # mutually exclusive and therefore redundant to list. + excluded_set = { + item + for item in (all_zero_conds - set(conds.items())) + if item[0] not in conds + } + excluded = [dict([item]) for item in sym.ordered(excluded_set)] + + # Pivot columns (all columns, including any augmented ones). + pivots = mat._get_rref_pivots() + + # Free parameters = LHS columns without a pivot. + n_pivot_lhs = sum(1 for p in pivots if p < n_var_cols) + free_params = n_var_cols - n_pivot_lhs + + # Consistency (only meaningful when rhs was provided). + is_consistent: bool | None = None + if rhs is not None: + is_consistent = mat._check_rref_consistency() + + # Preserve augmentation line from the original matrix. + aug = ( + working_mat._aug_pos.copy() + if hasattr(working_mat, "_aug_pos") + else set() + ) + rref_mat = Matrix(mat, aug_pos=aug) + + result_cases.append( + RREFCase( + conditions=conds, + excluded=excluded, + rref=rref_mat, + pivots=tuple(pivots), + free_params=free_params, + is_consistent=is_consistent, + ) + ) + + return result_cases + + # Override + def solve(self, rhs: Matrix, verbosity: int = 0) -> list[Matrix]: + """Solves the linear system `Ax = rhs` for `x`. + + This method uses SymPy's [`solve`][sympy.solvers.solvers.solve] method to find a solution vector `x` such that `self @ x = rhs`. + A list of solution matrices is returned. + If no solution exists (e.g., the system is inconsistent), a [ValueError][] is raised. + + Args: + rhs (Matrix): The right-hand side matrix or vector in the equation `Ax = rhs`. + + verbosity (int, optional): Level of verbosity for displaying intermediate steps: + - 0: No output. + - 1: Display the augmented matrix before and after RREF. + + Returns: + (list[Matrix]): A list of the solution vectors or matrices `x` that satisfies `Ax = rhs`. + + Raises: + ValueError: If no solution is found for the linear system, an exception is raised. + + Examples: + >>> A = Matrix([[1, 2], [3, 4]]) + >>> b = Matrix([[5], [11]]) + >>> A.solve(b) + [Matrix([ + [1] + [2] + ])] + + See Also: + - [`sympy.solve`][sympy.solvers.solvers.solve]: For solving equations in general. + - [`rref`][..]: For finding the reduced row echelon form of the matrix. + - [`solve_least_squares`][..]: For solving least squares problems. + """ + # Use sympy's solve function directly + x = Matrix.create_unk_matrix(r=self.cols, c=1) + solution = sym.solve(self @ x - rhs, x.free_symbols, dict=True) + + if verbosity >= 1: + print("Before RREF:") + display(self.row_join(rhs, aug_line=True)) + print("\nAfter RREF:") + display(self.row_join(rhs, aug_line=True).rref()) + + if len(solution) == 0: + # If no solution is found (e.g., inconsistent system or empty list), raise an error + if verbosity == 0: + display(self.row_join(rhs, aug_line=True).rref()) + raise ValueError( + "No solution found for the linear system. The system may be inconsistent." + ) + else: + return [x.subs(sol) for sol in solution] + + ############################# + # CHAPTER 2: MATRIX ALGEBRA # + ############################# + + def inverse( + self, + option: Literal["left", "right", "both"] | None = None, + matrices: int = 1, + verbosity: int = 0, + ) -> Matrix | PartGen | None: + """Computes the left or right inverse of a matrix, depending on its rank and the specified option. + + The method checks whether the matrix has full row rank or full column rank and computes either: + - The **left inverse** (if the matrix has full column rank). + - The **right inverse** (if the matrix has full row rank). + + If neither option is provided, the method automatically determines which inverse to compute based on the matrix's rank. + + Args: + option (str, optional): Specifies which inverse to compute: + + - `'left'` for the left inverse (requires the matrix to have full column rank). + - `'right'` for the right inverse (requires the matrix to have full row rank). + - `'both'` for the inverse of a square matrix (works on both sides). + + matrices (int, optional): Specifies the number of matrices to return: + + - 1: Returns only the inverse matrix. + - 2: Returns the particular and general solutions of the inverse. + + verbosity (int, optional): Level of verbosity for displaying intermediate steps: + + - 0: No output. + - 1: Display matrices before and after RREF. + + Returns: + (Matrix): If `matrices = 1`, returns the inverse matrix. + (PartGen): If `matrices = 2`, returns a dataclass containing the particular and general solutions of the inverse. + + Raises: + ValueError: If no valid inverse (left or right or both) is found, an exception is raised. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.inverse() + Matrix([ + [ -2, 1] + [3/2, -1/2] + ]) + """ + + if option is None: + rank = self.rank() + full_col = rank == self.cols + full_row = rank == self.rows + if full_col and full_row: + if verbosity: + print("Square inverse found!") + option = "both" + elif full_col: + if verbosity: + print("Left inverse found!") + option = "left" + elif full_row: + if verbosity: + print("Right inverse found!") + option = "right" + else: + raise ValueError( + f"No inverse found! Rank: {rank}, Rows: {self.rows}, Columns: {self.cols}. Try pseudo-inverse: .pinv()" + ) + + if option == "both" and self.rows != self.cols: + raise ValueError( + "Cannot compute both left and right inverse for non-square matrices!" + ) + + if (option is not None) and (verbosity >= 1): + if option == "left": + aug = self.T.copy().row_join( + Matrix.eye(self.cols, aug_pos=range(self.cols)) + ) + print("Before RREF: [self^T | eye]") + display(aug) + print("\nAfter RREF:") + display(aug.rref()) + else: + aug = self.copy().row_join( + Matrix.eye(self.rows, aug_pos=range(self.rows)) + ) + print("Before RREF: [self | eye]") + display(aug) + print("\nAfter RREF:") + display(aug.rref()) + + if option is not None: + X = Matrix.create_unk_matrix(r=self.cols, c=self.rows, symbol="x") + if option == "left": + eqn = X @ self - Matrix.eye(self.cols) + else: + eqn = self @ X - Matrix.eye(self.rows) + + sol = sym.solve(eqn, X.free_symbols) + if isinstance(sol, list) and len(sol) > 0: + # Multiple sets of solutions found, picks the first 1 + X = X.subs(sol[0]) + elif isinstance(sol, dict): + X = X.subs(sol) + else: + raise ValueError( + f"No {option} inverse found! Try pseudo-inverse: .pinv()" + ) + + if matrices == 1: + return X + elif matrices == 2: + return X.sep_part_gen() + + def elem(self) -> Matrix: + """Returns the identity matrix with the same number of rows as the current matrix. + + This method creates an identity matrix to be used for elementary row operations, + i.e., `A = I A` where `I` is the identity matrix. + + Returns: + (Matrix): An identity matrix with the same number of rows as the current matrix. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4], [5, 6]]) + >>> mat.elem() + Matrix([ + [1, 0, 0] + [0, 1, 0] + [0, 0, 1] + ]) + """ + return Matrix.eye(self.rows) + + # override + def adjoint(self) -> Matrix: + """Computes the adjugate (classical adjoint) of the matrix. + + This method calculates the classical adjoint (also known as the [adjugate](https://en.wikipedia.org/wiki/Adjugate_matrix) + in literature) of the matrix. The adjoint of a matrix (as defined in MA1522 syllabus) is the transpose of its cofactor matrix. + + Note: + If you wish to compute the conjugate transpose of the matrix (SymPy's definition for adjoint), + use `self.H` directly or `super(symbolic.Matrix, self).adjoint()` to call the parent method. + + Returns: + (Matrix): The classical adjoint (or adjugate) matrix of the current matrix. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> import pytest + >>> with pytest.warns(DeprecationWarning, match="The classical adjoint"): + ... mat.adjoint() + Matrix([ + [ 4, -2], + [-3, 1]]) + + See Also: + - SymPy's [`Matrix.adjugate`][sympy.matrices.matrixbase.MatrixBase.adjugate] + - SymPy's [`Matrix.adjoint`][sympy.matrices.matrixbase.MatrixBase.adjoint] for conjugate transpose. + """ + warn( + """The classical adjoint of the matrix is computed rather than the conjugate transpose. + Please use self.adj() instead to remove ambiguity.""", + DeprecationWarning, + stacklevel=2, + ) + return self.adjugate() + + def adj( + self, + method: Literal["bareiss", "berkowitz", "bird", "laplace", "lu"] = "berkowitz", + ) -> Matrix: + """Alias for the [`adjoint`][..] method. + + It returns the classical adjoint (or [adjugate](https://en.wikipedia.org/wiki/Adjugate_matrix)) of the matrix. + + Args: + method (str, optional): Method to use to find the cofactors, can be "bareiss", "berkowitz", + "bird", "laplace" or "lu". + Returns: + (Matrix): The classical adjoint of the current matrix. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.adj() + Matrix([ + [ 4, -2], + [-3, 1]]) + + See Also: + - SymPy's [`adjugate`][sympy.matrices.matrixbase.MatrixBase.adjugate] + """ + return self.adjugate(method=method) + + # override + def cramer_solve( + self, rhs: Matrix, det_method: str = "laplace", verbosity: int = 2 + ) -> Matrix: + """Solves the linear system using Cramer's Rule. + + This method applies Cramer's Rule to solve the linear system represented by the matrix and the right-hand side vector. + It computes the determinant of the matrix and uses it to find the solution vector. + + Args: + rhs (Matrix): The right-hand side vector in the equation `Ax = rhs`. + det_method (str, optional): The method to use for computing the determinant. Options include: + + - `'laplace'`: Uses the Laplace expansion method. + - `'berkowitz'`: Uses the Berkowitz algorithm. + - `'bird'`: Uses the Bird's algorithm. + - `'bareiss'`: Uses the Bareiss algorithm. + - `'lu'`: Uses LU decomposition. + + verbosity (int, optional): Level of verbosity for displaying intermediate steps: + + - 0: No output. + - 1: Display basic information. + - 2: Display detailed information. + + Returns: + (Matrix): The solution vector `x` that satisfies `self @ x = rhs`. + + Raises: + sympy.matrices.exceptions.NonSquareMatrixError: If the matrix is not square. + sympy.matrices.exceptions.ShapeError: If the matrix and the right-hand side vector have incompatible dimensions. + ValueError: If the determinant is zero, indicating that the system has no unique solution. + + Examples: + >>> A = Matrix([[1, 2], [3, 4]]) + >>> b = Matrix([[5], [11]]) + >>> A.cramer_solve(b, verbosity=0) + Matrix([ + [1] + [2] + ]) + + See Also: + - [`solve`][..]: For solving linear systems using other methods. + - SymPy's [`Matrix.det`][sympy.matrices.matrixbase.MatrixBase.det] for computing the determinant. + - SymPy's [`Matrix.cramer_solve`][sympy.matrices.matrixbase.MatrixBase.cramer_solve] + """ + if self.rows != rhs.rows: + raise sym.ShapeError( + "The right-hand side vector must have the same number of rows as the matrix." + ) + if rhs.cols != 1: + raise sym.ShapeError( + "The right-hand side vector must be a column vector (1 column)." + ) + if self.rows != self.cols: + raise sym.NonSquareMatrixError( + "Cramer's Rule can only be applied to square matrices." + ) + det = self.det(method=det_method) + if det == 0: + raise ValueError("Determinant is zero, no unique solution exists.") + + entries = [] + for i in range(self.cols): + # Create a copy of the matrix and replace the i-th column with the rhs vector + modified_matrix = self.copy() + modified_matrix[:, i] = rhs[:, 0] + if verbosity >= 2: + print(f"Modified matrix for column {i + 1}:") + display(modified_matrix) + det_i = modified_matrix.det(method=det_method) / det + if verbosity >= 1: + display( + _textify("Determinant for column ") + + str(i + 1) + + ": " + + sym.latex(det_i), + opt="math", + ) + entries.append(det_i) + return Matrix(entries) + + def column_constraints(self, use_ref: bool = False, verbosity: int = 1) -> Matrix: + r"""Computes the column constraints for the matrix by appending a symbolic vector. + + This method creates a matrix where a random column vector $\begin{pmatrix} x_1 \\ \vdots \\ x_m \end{pmatrix}$ + is added to the matrix as an additional column. It then constructs a larger augmented matrix + and optionally computes its Row Echelon Form (REF) or Reduced Row Echelon Form (RREF). + + The method modifies the matrix to ensure that the unknown vector is not reduced in RREF, + and the constraints for the matrix columns are calculated accordingly. + + Args: + use_ref (bool, optional): Whether to use Row Echelon Form (REF) instead of Reduced Row Echelon Form (RREF). + If `False`, RREF will be used. + verbosity (int, optional): Verbosity level for displaying information. + + - 0: No output + - 1: Display all information + + Returns: + (Matrix): A new matrix containing the result after applying REF or RREF to the augmented matrix. + + Examples: + >>> mat = Matrix([[1, 2], [2, 4]]) # linearly dependent columns + >>> mat.column_constraints(verbosity=0) + Matrix([ + [1, 2 | x_2/2] + [0, 0 | x_1 - x_2/2] + ]) + + >>> mat = Matrix([[1, 2], [3, 4]]) # linearly independent columns + >>> mat.column_constraints(verbosity=0) + Matrix([ + [1, 0 | -2*x_1 + x_2] + [0, 1 | 3*x_1/2 - x_2/2] + ]) + """ + + # write a random vector as x_1, ..., x_m, given m rows + vector = Matrix.create_unk_matrix(self.rows, 1, "x") + + # insert hidden column vectors so that the unknown vector is not reduced in rref + hidden = self.elem() + + M = self.copy().row_join(hidden).row_join(vector) + if use_ref: + res = M.ref().U + else: + res = M.rref(pivots=False) + + visible_cols = (*range(self.cols), -1) + res_matrix = res.select_cols(*visible_cols).aug_line(-2) # type: ignore + + if verbosity: + print("Before RREF: [self | vec]") + display(M.select_cols(*visible_cols).aug_line(-2)) + print("After RREF") + display(res_matrix) + print( + "For the system to be consistent, the following constraints must be satisfied." + ) + for i in range(res_matrix.rows): + # check for zero row + if res_matrix[i, : self.cols].norm() == 0: # type: ignore + display(sym.Eq(res_matrix[i, -1], 0)) + return res_matrix + + ###################################### + # CHAPTER 3: EUCLIDEAN VECTOR SPACES # + ###################################### + + def normalized( + self, + iszerofunc: Callable[[Expr], int] | None = None, + factor: bool = False, + ) -> Matrix | ScalarFactor: + """Normalizes the column vectors of the matrix (scaling each vector to have a unit norm). + + Args: + iszerofunc (Callable[[Expr], int], optional): Function to determine if a value should be treated as zero + factor (bool, optional): If `True`, returns the [`ScalarFactor`][(p).] dataclass. + If `False`, only the normalized matrix is returned. + + Returns: + (ScalarFactor): A dataclass containing the normalized matrix and the diagonal matrix of norms if `factor=True`. + (Matrix): The normalized matrix if `factor=False`. + + Examples: + >>> mat = Matrix([[0, 3], [0, 4]]) + >>> mat.normalized() + Matrix([ + [0, 3/5] + [0, 4/5] + ]) + + >>> mat = Matrix([[3], [4]]) + >>> mat.normalized(factor=True) + ScalarFactor(diag=Matrix([[1/5] + ]), full=Matrix([ + [3] + [4] + ]), order='FD') + """ + + for i in range(self.cols): + col = self.col(i) + scalar = sym.sqrt(sum(x**2 for x in col)) # Manual norm calculation + if iszerofunc is None: + if scalar != 0: + self[:, i] /= scalar # type: ignore + else: + if iszerofunc(scalar) != 0: + self[:, i] /= scalar # type: ignore + + if factor: + return self.scalar_factor(column=True) + else: + return self + + def is_linearly_independent( + self, + colspace: bool = True, + verbosity: int = 0, + ) -> bool: + """Determines if the vectors in the matrix are linearly independent. + + This method checks whether the columns (or rows) of the matrix are linearly independent + by computing its reduced row echelon form (RREF) and comparing the number of pivot columns + to the number of columns (or rows). + + Args: + colspace (bool, optional): If `True`, checks linear independence of columns (column space). + If `False`, checks linear independence of rows (row space). + + verbosity (int, optional): Level of output during the RREF and check. + + - 0: No output (default). + - 1: Print summary of the RREF and check. + - 2: Print the matrix before and after RREF, and the check details. + + Returns: + (bool): `True` if the matrix's columns (or rows) are linearly independent, `False` otherwise. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.is_linearly_independent() + True + + >>> mat = Matrix([[1, 2], [2, 4]]) + >>> mat.is_linearly_independent() + False + """ + rref = self.rref(pivots=True) + assert isinstance(rref, RREF), "RREF should return a RREF dataclass" + rref_mat, pivots = rref.rref, rref.pivots + + if verbosity == 1: + print("rref(self)") + elif verbosity >= 2: + print("Before RREF: self") + display(self) + print("\nAfter RREF:") + display(rref_mat) + + if colspace: + if verbosity >= 1: + print( + f"Check if Number of columns ({self.cols}) == Number of pivot columns ({len(pivots)})" # type: ignore + ) + return self.cols == len(pivots) + else: + if verbosity >= 1: + print( + f"Check if Number of rows ({self.rows}) == Number of pivot columns ({len(pivots)})" # type: ignore + ) + return self.rows == len(pivots) + + def get_linearly_independent_vectors( + self, colspace: bool = True, verbosity: int = 1 + ) -> Matrix: + """Returns a matrix containing the linearly independent vectors from the column space or row space. + + This method computes the reduced row echelon form (RREF) of the matrix and selects the non-zero rows + as linearly independent vectors. The result is a matrix whose columns (or rows) are linearly independent. + + Args: + colspace (bool, optional): If `True`, returns linearly independent vectors from the column space. + If `False`, returns from the row space. + + verbosity (int, optional): Level of output verbosity. + + - 0: No output. + - 1: Print a summary of the RREF and selection. + + Returns: + (Matrix): A matrix whose columns (if colspace=True) or rows (if colspace=False) are linearly independent vectors. + + Examples: + >>> mat = Matrix([[1, 2], [2, 4]]) + >>> mat.get_linearly_independent_vectors(colspace=True, verbosity=0) + Matrix([ + [1] + [2] + ]) + """ + if colspace: + rref = self.rref(pivots=True) + assert isinstance(rref, RREF), "RREF should return a RREF dataclass" + if verbosity >= 1: + print("Before RREF: [self]") + display(self) + print("\nAfter RREF:") + display(rref) + print("Select columns of self corresponding to pivot positions.") + return self.select_cols(*rref.pivots) + else: + rref = self.T.rref(pivots=True) + assert isinstance(rref, RREF), "RREF should return a RREF dataclass" + if verbosity >= 1: + print("Before RREF: [self^T]") + display(self.T) + print("\nAfter RREF:") + display(rref) + print("Select rows of self corresponding to pivot positions.") + return self.select_rows(*rref.pivots) + + def simplify_basis(self, colspace: bool = True, verbosity: int = 2) -> Matrix: + """Returns a simplified basis for the column space or row space of the matrix. + + This method computes a basis for either the column space or the row space of the matrix + by reducing the matrix (or its transpose) to reduced row echelon form (RREF) and selecting + the nonzero rows as basis vectors. The result is a matrix whose columns (or rows) form a basis + for the specified subspace. + + Args: + colspace (bool, optional): If `True`, returns a basis for the column space. + If `False`, returns a basis for the row space. + + verbosity (int, optional): Level of output verbosity. + + - 0: No output. + - 1: Print a summary of the RREF and basis selection. + - 2: Print the matrix before and after RREF, and show the selected basis vectors. + + Returns: + (Matrix): A matrix whose columns (if colspace=True) or rows (if colspace=False) form a basis + for the corresponding subspace. + + Examples: + >>> mat = Matrix([[1, 2], [2, 4]]) + >>> mat.simplify_basis(colspace=True, verbosity=0) + Matrix([ + [1] + [2] + ]) + + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.simplify_basis(colspace=False, verbosity=0) + Matrix([ + [1, 0] + [0, 1] + ]) + """ + if colspace: + rref_mat = self.T.rref(pivots=False) + assert isinstance(rref_mat, Matrix), "RREF should return a Matrix" + if verbosity == 1: + print("Select non-zero rows of rref(self.T) as basis vectors.") + if verbosity >= 2: + print("Before RREF: self^T") + display(self.T) + print("\nAfter RREF:") + display(rref_mat) + else: + rref_mat = self.rref(pivots=False) + assert isinstance(rref_mat, Matrix), "RREF should return a Matrix" + if verbosity == 1: + print("Select non-zero rows of rref(self) as basis vectors.") + if verbosity >= 2: + print("Before RREF: self") + display(self) + print("\nAfter RREF:") + display(rref_mat) + + idxs = [] + for i in range(rref_mat.rows): + if any(not _is_zero(x) for x in rref_mat[i, :]): # type: ignore + idxs.append(i) + + mat = rref_mat.select_rows(*idxs) + if colspace: + return mat.T + else: + return mat + + def extend_basis( + self, span_subspace: Matrix | None = None, verbosity: int = 2 + ) -> Matrix: + r"""Extends the matrix to form a basis for the span of the given subspace. + + This method extends the column space of the current matrix to include the columns of the provided + `span_subspace`, computes the Reduced Row Echelon Form (RREF) of the augmented matrix, + and then selects the pivot columns to return the extended basis. + + If no `span_subspace` is provided, the identity matrix (i.e. $\mathrm{span} \left(\mathbb{R}^n \right)$) is used as the default. + The result is a matrix with the extended basis that spans the combined space of the `self` + matrix and the `span_subspace`. + + Args: + span_subspace (Matrix, optional): A matrix whose columns represent the subspace to + be added to the current matrix. If `None`, the identity matrix is used. + + verbosity (int, optional): Verbosity level for displaying information. + + - 0: No output. + - 1: Display steps. + - 2: Display the matrix before and after RREF. + + Returns: + (Matrix): A matrix whose column space represents the extended basis, consisting of the pivot columns + from the RREF of the augmented matrix. + + Examples: + >>> mat = Matrix([[1, 2], [2, 4]]) + >>> mat.extend_basis(verbosity=0) + Matrix([ + [1, 1] + [2, 0] + ]) + """ + + if span_subspace is None: + span_subspace = self.elem() + aug = self.copy().row_join(span_subspace) + rref = aug.rref(pivots=True) + assert isinstance(rref, RREF), "RREF should return a RREF dataclass" + + if verbosity == 1: + print("rref([self | span_subspace])") + elif verbosity >= 2: + print("Before RREF: [self | span_subspace]") + display(aug) + print("\nAfter RREF:") + display(rref) + print( + "Select columns of rref([self | span_subspace]) corresponding to pivot positions." + ) + + return aug.select_cols(*rref.pivots) + + def intersect_subspace(self, other: Matrix, verbosity: int = 2) -> Matrix: + """Computes the intersection of two subspaces by finding the nullspace of their orthogonal complements. + + This method computes the intersection of the subspaces spanned by the columns of the current matrix + (`self`) and the provided matrix (`other`). The intersection is computed by finding the union of the nullspace of + the row space of the two matrices, and then finding its orthogonal complement. + + Args: + other (Matrix): The second matrix representing the other subspace to intersect with the current matrix. + verbosity (int, optional): Level of verbosity for displaying intermediate steps: + + - 0: No output. + - 1: Display steps. + - 2: Display the relevant matrices. + Defaults to 2. + + Returns: + (Matrix): A matrix whose columns form a basis for the intersection of the two subspaces. + + Examples: + >>> mat1 = Matrix([[1, 0], [0, 1]]) + >>> mat2 = Matrix([[1, 1], [0, 0]]) + >>> mat1.intersect_subspace(mat2, verbosity=0) + Matrix([ + [1] + [0] + ]) + """ + + # Construct 2 matrices A and B, whose solution space (ie nullspace) is + # the subspace self and other respectively. Observe that the solution + # space is orthogonal to the row space, so it is the orthogonal complement. + + A = self.orthogonal_complement().T + B = other.orthogonal_complement().T + + # Now we obtain A and B which represent the linear system of 2 different + # subspaces. When we solve these simultaneously, we will find the solution + # space which contains vectors which are solutions to both linear systems. + aug = A.col_join(B) + if verbosity == 1: + print("A = Null(self^T)^T") + print("B = Null(other^T)^T") + print("Null([A ; B])") + + if verbosity >= 2: + print( + "A linear system whose solution space is the subspace of self. Null(self^T)^T" + ) + display(A) + print( + "\nA linear system whose solution space is the subspace of other. Null(other^T)^T" + ) + display(B) + print("\nBefore RREF: [self ; other]") + display(aug) + print("\nAfter RREF:") + display(aug.rref()) + + return Matrix.from_list(aug.nullspace()) + + def is_subspace_of(self, other: Matrix, verbosity: int = 2) -> bool: + r"""Checks if the current matrix is a subspace of another matrix. + + This method determines whether the subspace spanned by the columns of the current matrix (`self`) + is a subspace of the provided matrix (`other`). It does so by checking if the row-reduced echelon form + (RREF) of the augmented matrix `[other | self]`. + + Args: + other (Matrix, optional): The second matrix representing the other subspace to compare with the current matrix. + If `None`, the identity matrix is used to check if it spans $\mathbb{R}^\text{self.rows}}$ + verbosity (int, optional): Level of verbosity for displaying intermediate steps: + + - 0: No output. + - 1: Display the steps. + - 2: Display the relevant matrices. + + Returns: + (bool): `True` if the subspace spanned by `self` is a subspace of `other`, `False` otherwise. + + Raises: + sympy.matrices.exceptions.ShapeError: If the number of rows in the current matrix and the target matrix are different. + + Examples: + >>> mat1 = Matrix([[1, 0], [0, 1]]) + >>> mat2 = Matrix([[1], [0]]) + >>> mat2.is_subspace_of(mat1, verbosity=0) + True + """ + if self.rows != other.rows: + raise sym.ShapeError( + f"The matrices have incompatible number of rows ({self.rows}, {other.rows})" + ) + + aug = other.copy().row_join(self) + sub = aug.rref(pivots=True) + assert isinstance(sub, RREF), "RREF should return a RREF dataclass" + if not sub.pivots: + if verbosity: + print("RREF has no pivots, so span(self) is the zero subspace.") + print("The zero subspace is trivially a subspace of span(other).\n") + return True + if verbosity == 1: + print("Check rref([other | self])") + if verbosity >= 2: + print("Check if span(self) is subspace of span(other)") + print("\nBefore RREF: [other | self]") + display(aug) + print("\nAfter RREF:") + display(sub) + if max(sub.pivots) >= other.cols: + print("Span(self) is not a subspace of span(other).\n") + else: + print("Span(self) is a subspace of span(other).\n") + + return max(sub.pivots) < other.cols + + def is_same_subspace(self, other: Matrix | None = None, verbosity: int = 2) -> bool: + r"""Checks if two subspaces are the same by verifying if each subspace is a subspace of the other. + + This method determines whether the subspaces spanned by the columns of the current matrix (`self`) + and the provided matrix (`other`) are the same. It does so by calling the `is_subspace_of` method + twice: first to check if `self` is a subspace of `other`, and then to check if `other` is a subspace of `self`. + If both checks return `True`, then the subspaces are considered the same. + + Args: + other (Matrix, optional): The second matrix representing the other subspace to compare with the current matrix. + If `None`, the identity matrix is used to check if it spans $\mathbb{R}^\text{self.rows}}$ + verbosity (int, optional): Level of verbosity for displaying intermediate steps: + + - 0: No output. + - 1: Display the steps. + - 2: Display the relevant matrices. + Defaults to 2. + + Returns: + (bool): `True` if the subspaces spanned by `self` and `other` are the same, `False` otherwise. + + Raises: + sympy.matrices.exceptions.ShapeError: If the number of rows in the current matrix and the target matrix are different. + + Examples: + >>> mat1 = Matrix([[1, 0], [0, 1]]) + >>> mat2 = Matrix([[1, 2], [3, 4]]) + >>> mat1.is_same_subspace(mat2, verbosity=0) + True + """ + if other is None: + rref = self.rref(pivots=True) + assert isinstance(rref, RREF), "RREF should return a RREF dataclass" + if verbosity >= 1: + print("Check rref(self) does not have zero rows") + if verbosity >= 2: + print("Before RREF: self") + display(self) + print("\nAfter RREF:") + display(rref.rref) + return len(rref.pivots) == self.rows # no zero rows + + if self.rows != other.rows: + raise sym.ShapeError( + f"The matrices have incompatible number of rows ({self.rows}, {other.rows})" + ) + + if verbosity >= 1: + print("Check if span(self) is subspace of span(other), and vice versa.") + return self.is_subspace_of( + other=other, verbosity=verbosity + ) and other.is_subspace_of(other=self, verbosity=verbosity) + + def coords_relative(self, basis: Matrix, verbosity: int = 2) -> Matrix: + """Computes the coordinates of the current vector relative to a given basis. + + This method finds the coordinate vector `c` such that `self = basis @ c`, + where `basis` is a matrix whose columns form a basis, and `self` is a column vector. The method + achieves this by augmenting the target matrix with the current matrix, performing + Reduced Row Echelon Form (RREF), and extracting the appropriate part of the resulting matrix. + + Args: + basis (Matrix): The matrix whose columns form the target basis. + + verbosity (int, optional): Level of output verbosity. + + - 0: No output. + - 1: Print summary of the RREF and solution. + - 2: Print the matrix before and after RREF, and show the solution details. + + Returns: + (Matrix): The coordinate vector of `self` relative to the basis `to`. + + Raises: + sympy.matrices.exceptions.ShapeError: If `self` is not a column vector + or if the number of rows in `self` and `basis` do not match. + ValueError: If the system is inconsistent and no solution exists. + + Examples: + >>> v = Matrix([[3], [7]]) + >>> B = Matrix([[1, 2], [1, 3]]) + >>> v.coords_relative(B, verbosity=0) + Matrix([ + [-5] + [ 4] + ]) + """ + if self.cols != 1: + raise sym.ShapeError( + f"self should be a vector with 1 column. ({self.cols})" + ) + if self.rows != basis.rows: + raise sym.ShapeError( + f"The matrices have incompatible number of rows ({self.rows}, {basis.rows})" + ) + + M = basis.copy().row_join(self) + rref = M.rref(pivots=True) + assert isinstance(rref, RREF), "RREF should return a RREF dataclass" + rref_mat, pivots = rref.rref, rref.pivots + + if verbosity == 1: + print("Solve system via rref([to | self])") + elif verbosity >= 2: + print("Before RREF: [to | self]") + display(M) + print("\nAfter RREF:") + display(rref_mat) + + if basis.cols in pivots: + raise ValueError("No solution found due to inconsistent system.") + + sol = basis.solve(self)[0] # solution should be unique + assert isinstance(sol, Matrix), "Solution should be a Matrix (vector) object" + return sol + + def transition_matrix(self, to: Matrix, verbosity: int = 2) -> Matrix: + """Computes the transition matrix that transforms this matrix to another matrix. + + This method computes the transition matrix `P` such that `self = P @ to`, + where `to` is the target basis, and `self` is the current basis. The method + achieves this by augmenting the target matrix with the current matrix, performing + Reduced Row Echelon Form (RREF), and extracting the appropriate part of the resulting matrix. + + Args: + to (Matrix): The matrix to which the current matrix should be transformed. + + verbosity (int, optional): Verbosity level for displaying information. + + - 0: No output. + - 1: Display the steps. + - 2: Display the relevant matrices. + + Returns: + (Matrix): The transition matrix `P` that satisfies `self = P @ to`. + + Raises: + AssertionError: If the columns of the `self` matrix and `to` matrix do not span the same subspace. + + Examples: + >>> mat1 = Matrix([[1, 0], [0, 1]]) + >>> mat2 = Matrix([[2, 0], [0, 2]]) + >>> mat1.transition_matrix(to=mat2, verbosity=0) + Matrix([ + [1/2, 0], + [ 0, 1/2]]) + """ + assert self.is_same_subspace(to, verbosity=0), ( + "Column vectors of both matrices must span the same subspace." + ) + + M = to.copy().row_join(self) + res = M.rref(pivots=False) + assert isinstance(res, Matrix), "RREF should return a Matrix object" + if verbosity == 1: + print("rref([to | self])") + elif verbosity >= 2: + print("Before RREF: [to | self]") + display(M) + print("\nAfter RREF:") + display(res) + P = res[: to.cols, to.cols :] + return P # type: ignore + + ############################################### + # CHAPTER 4: SUBSPACES ASSOCIATED TO A MATRIX # + ############################################### + + # Override + def nullspace(self, verbosity: int = 0, *args, **kwargs) -> list[Matrix]: + """ + Computes the null space (kernel) of the matrix, i.e., the set of vectors that satisfy `self @ x = 0`. + + This method utilizes the rank-nullity theorem to determine if the null space exists. Fixes the + issue with SymPy [implementation][sympy.matrices.matrixbase.MatrixBase.nullspace] of nullspace where + it raises an exception if the nullspace is trivial (only contain the zero vector). + If the matrix has full column rank (i.e., rank = number of columns), it has no non-trivial null space, + and an empty list is returned. + + Args: + verbosity (int, optional): Level of verbosity for displaying intermediate steps. + + - 0: No output. + - 1: Display the matrix before and after row-reduction (RREF). + *args: Additional positional arguments passed to SymPy's [`nullspace`][sympy.matrices.matrixbase.MatrixBase.nullspace] method. + **kwargs: Additional keyword arguments passed to SymPy's [`nullspace`][sympy.matrices.matrixbase.MatrixBase.nullspace] method. + + Returns: + list[Matrix]: A list of [`Matrix`][...] objects representing the null space vectors. Returns an empty list if the null space is trivial. + + Examples: + >>> mat = Matrix([[1, 2], [3, 6]]) + >>> mat.nullspace() + [Matrix([ + [-2], + [ 1]])] + + See Also: + - SymPy's [`Matrix.nullspace`][sympy.matrices.matrixbase.MatrixBase.nullspace] + - [`orthogonal_complement`][..] for computing the orthogonal complement of the matrix. + """ + + # Issue with SymPy implementation of nullspace when there is None + # Using rank nullity theorem to verify there are vectors spanning nullspace + if verbosity >= 1: + print("Before RREF: [self]") + display(self) + print("\nAfter RREF:") + display(self.rref()) + + if self.rank() == self.cols: + if verbosity >= 1: + warn( + "Only trivial nullspace (0-vector) detected!", + UserWarning, + stacklevel=2, + ) + return [] + else: + return super().nullspace(*args, **kwargs) + + def nullity(self) -> int: + """Computes the nullity of the matrix, which is the dimension of its null space. + + The nullity is defined as the number of free variables in the solution to the homogeneous equation `self @ x = 0`. + It can be computed as `nullity = cols - rank`, where `cols` is the number of columns in the matrix and `rank` is its rank. + + Returns: + (int): The nullity of the matrix. + + Examples: + >>> mat = Matrix([[1, 2], [3, 6]]) + >>> mat.nullity() + 1 + """ + return self.cols - self.rank() + + ####################################################### + # CHAPTER 5: ORTHOGONALITY AND LEAST SQUARES SOLUTION # + ####################################################### + + def orthogonal_complement(self, verbosity: int = 0) -> Matrix: + """Computes the orthogonal complement of the matrix (the null space of its transpose). + + The orthogonal complement consists of all vectors that are orthogonal to the column space of the matrix. + This method computes the null space of the transpose of the matrix, which gives a basis for the orthogonal complement. + + Note: + The orthogonal complement is the set of all vectors `v` such that `self^T @ v = 0`. + + Args: + verbosity (int, optional): Level of verbosity for debugging. + + - 0: No output. + - 1: Display the matrix before and after row-reduction (RREF). + + + Returns: + (Matrix): A matrix whose columns form a basis for the orthogonal complement. + + Examples: + >>> mat = Matrix([[1, 0], [0, 0]]) + >>> mat.orthogonal_complement() + Matrix([ + [0] + [1] + ]) + """ + + return Matrix.from_list(self.transpose().nullspace(verbosity)) + + def is_vec_orthogonal(self, verbosity: int = 1) -> bool: + r"""Checks if the column vectors of the matrix are orthogonal to each other. + + This method computes `self^T @ self` and checks if the result is diagonal. + If the result is diagonal, the vectors are orthogonal (i.e., $u_i \dot u_j = 0 \forall i != j$). + + Note: + This method checks for orthogonality, not orthonormality. For orthonormality, use [`is_mat_orthogonal`][..]. + + Args: + verbosity (int, optional): Level of verbosity for displaying intermediate results. + + - 0: No output. + - 1: Display the matrix product `self.T @ self`. + + Returns: + (bool): `True` if the column vectors are orthogonal, `False` otherwise. + + Examples: + >>> mat = Matrix([[1, 0], [0, 2]]) + >>> mat.is_vec_orthogonal(verbosity=0) + True + """ + + res = self.T @ self + if verbosity >= 1: + print("Check if [self^T @ self] is a diagonal matrix") + display(res) + return res.is_diagonal() + + def is_mat_orthogonal(self, verbosity: int = 1) -> bool: + """ + Checks if the matrix is orthogonal (i.e., its columns are orthonormal). + + A matrix is orthogonal if its columns are orthonormal, i.e., if `self.T @ self` is the identity matrix. + This method computes `self.T @ self` and checks if the result is an identity matrix. + + Args: + verbosity (int, optional): Level of verbosity for displaying intermediate results. + + - 0: No output. + - 1: Display the matrix product `self.T @ self`. + + Returns: + (bool): True if the matrix is orthogonal, False otherwise. + + Examples: + >>> mat = Matrix([[1, 0], [0, 1]]) + >>> mat.is_mat_orthogonal(verbosity=0) + True + + >>> mat = Matrix([[1, 0], [0, 2]]) + >>> mat.is_mat_orthogonal(verbosity=0) + False + """ + + res = self.T @ self + if verbosity >= 1: + print("self^T @ self") + display(res) + return res.is_diagonal() and all(entry == 1 for entry in res.diagonal()) + + def orthogonal_decomposition(self, to: Matrix, verbosity: int = 0) -> VecDecomp: + """Decomposes the current vector (or matrix) into its orthogonal projection onto a subspace and its orthogonal complement. + + This method computes the orthogonal decomposition of `self` relative to the subspace spanned by the columns of `to`. + It finds the projection of `self` onto the subspace (`proj`) and the component orthogonal to the subspace (`norm`), such that: + `self = proj + norm` + + The projection is computed using the least squares solution. + + Args: + to (Matrix): The matrix whose columns form the subspace onto which to project `self`. + verbosity (int, optional): Level of verbosity for displaying intermediate results. + + - 0: No output. + - 1: Display the projected and normal components. + - 2: Display detailed steps. + + Returns: + (VecDecomp): A dataclass with fields: + + - proj ([`Matrix`][...]): The projection of `self` onto the subspace spanned by `to`. + - norm ([`Matrix`][...]): The component of `self` orthogonal to the subspace spanned by `to`. + + Examples: + >>> v = Matrix([[3], [4]]) + >>> B = Matrix([[1], [0]]) + >>> v.orthogonal_decomposition(B) + VecDecomp(proj=Matrix([ + [3], + [0]]), norm=Matrix([ + [0] + [4] + ])) + """ + + sol = to.solve_least_squares(self, verbosity=verbosity) + proj = to @ sol + norm = self - proj + + if verbosity >= 1: + print("Projected component: Au") + display(proj) + print("Normal component: b - b_proj") + display(norm) + + assert proj + norm == self + return VecDecomp(proj, norm) + + def proj_comp(self, to: Matrix, verbosity: int = 0) -> Matrix: + """Computes the orthogonal projection of the current vector (or matrix) onto the subspace spanned by the columns of another matrix. + + This method returns the component of `self` that lies in the subspace defined by the columns of `to`. + It is equivalent to the projection of `self` onto the subspace, as computed by the orthogonal decomposition. + + Args: + to (Matrix): The matrix whose columns form the subspace onto which to project `self`. + verbosity (int, optional): Level of verbosity for displaying intermediate results. + + - 0: No output. + - 1: Display the projected component. + + Returns: + (Matrix): The projection of `self` onto the subspace spanned by `to`. + + Examples: + >>> v = Matrix([[3], [4]]) + >>> B = Matrix([[1], [0]]) + >>> v.proj_comp(B) + Matrix([ + [3], + [0]]) + """ + + return self.orthogonal_decomposition(to=to, verbosity=verbosity).proj + + def norm_comp(self, to: Matrix, verbosity: int = 0) -> Matrix: + """Computes the component of the current vector (or matrix) orthogonal to the subspace spanned by the columns of another matrix. + + This method returns the part of `self` that is perpendicular to the subspace defined by the columns of `to`. + It is equivalent to the normal component from the orthogonal decomposition. + + Args: + to (Matrix): The matrix whose columns form the subspace to which the orthogonal component is computed. + verbosity (int, optional): Level of verbosity for displaying intermediate results. + + - 0: No output (default). + - 1: Display the normal component. + + Returns: + (Matrix): The component of `self` orthogonal to the subspace spanned by `to`. + + Examples: + >>> v = Matrix([[3], [4]]) + >>> B = Matrix([[1], [0]]) + >>> v.norm_comp(B) + Matrix([ + [0] + [4] + ]) + """ + + return self.orthogonal_decomposition(to=to, verbosity=verbosity).norm + + def gram_schmidt( + self, factor: bool = True, verbosity: int = 1 + ) -> Matrix | ScalarFactor: + """Performs Gram-Schmidt orthogonalization to convert a set of vectors (columns of the matrix) into + an orthogonal set (that includes 0 vectors if any). + + Args: + factor (bool): If `True`, the resulting orthogonal vectors will be scaled to have integer factors. + verbosity (int): Level of verbosity: + + - 0: No output. + - 1: Display intermediate results for each step of the process. + + Returns: + (Matrix): A matrix whose columns are the orthogonalized vectors. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.gram_schmidt(factor=False, verbosity=0) + Matrix([ + [ sqrt(10)/10, 3*sqrt(10)/10] + [3*sqrt(10)/10, -sqrt(10)/10] + ]) + """ + + if self.cols == 0: + return Matrix() + if verbosity >= 1: + display(f"v_{1} = {sym.latex(self.select_cols(0))}", opt="math") + + orthogonal_set = [self.select_cols(0)] + for i in range(1, self.cols): + u = self.select_cols(i) + u_orig = u.copy() + latex_eq = f"v_{i + 1} = {sym.latex(u)}" + for _, v in enumerate(orthogonal_set, start=1): + if v.norm() != 0: + numerator = v.dot(u_orig, hermitian=True) + denominator = v.dot(v, hermitian=True) + latex_eq += f"- \\left(\\frac{{{sym.latex(numerator)}}}{{{sym.latex(denominator)}}}\\right) {sym.latex(v)}" + u -= (numerator / denominator) * v + + if verbosity >= 1: + disp_u = u.copy() + if factor: + scalar = sym.gcd(tuple(u)) # type: ignore + disp_u = sym.MatMul(scalar, u / scalar, evaluate=False) + latex_eq += f" = {sym.latex(disp_u)}" + display(latex_eq, opt="math") + + if u.norm() == 0 and (verbosity >= 1): + warn( + "Vectors are linearly dependent. Note that there is no QR factorisation", + UserWarning, + stacklevel=2, + ) + orthogonal_set.append(u) + + return Matrix.from_list(orthogonal_set).normalized(factor=factor) + + # Override + def QRdecomposition(self, full: bool = False, verbosity: int = 0) -> QR: + """Computes the QR decomposition of the matrix. Optionally computes the full QR decomposition. + + A full QR decomposition returns an **orthogonal (square) matrix** `Q` and an upper triangular matrix `R` + such that `self = Q @ R`. On the other hand, a reduced QR decomposition returns `Q` and `R` such that + `self = Q @ R`, where `Q` has **orthonormal columns**. + + Args: + full (bool): If `True`, computes the full QR decomposition. + verbosity (int, optional): Level of verbosity for displaying intermediate results: + + - 0: No output. + - 1: Display intermediate results for each step of the process. + + Returns: + (QR): A dataclass containing: + + - `Q` ([`Matrix`][...]): A matrix with orthonormal columns (or orthogonal matrix if full QR decomposition). + - `R` ([`Matrix`][...]): An upper triangular matrix. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.QRdecomposition() + QR(Q=Matrix([ + [ sqrt(10)/10, 3*sqrt(10)/10] + [3*sqrt(10)/10, -sqrt(10)/10] + ]), R=Matrix([ + [sqrt(10), 7*sqrt(10)/5] + [ 0, sqrt(10)/5] + ])) + + See Also: + - SymPy's [`Matrix.QRdecomposition`][sympy.matrices.matrixbase.MatrixBase.QRdecomposition] + - [`gram_schmidt`][..] for performing the Gram-Schmidt process to find an orthogonal basis. + """ + if verbosity >= 1: + print("Finding orthogonal basis via Gram-Schmidt process:") + Q = self.gram_schmidt(factor=False, verbosity=verbosity) + assert isinstance(Q, Matrix), "Result should be a Matrix object" + print("Q matrix:") + display(Q) + print("R matrix: Q.T @ self") + display(Q.T @ self) + + # Modified SymPy's implementation to compute full QR decomposition if required. + Q, R = super().QRdecomposition() + if full and Q.rows != Q.cols: + Q = Matrix(Q) + complement = Q.orthogonal_complement(verbosity=0) + orth_complement = complement.gram_schmidt(factor=False, verbosity=0) + Q_aug = Q.row_join(orth_complement, aug_line=False) + R_aug = Matrix(R.col_join(sym.zeros(Q_aug.cols - R.rows, R.cols))) + assert (Q_aug @ R_aug).equals(self), ( + "Full QR decomposition failed: Q_aug @ R_aug != self" + ) + return QR(Q_aug, R_aug) + return QR(Q, R) + + def solve_least_squares( + self, rhs: Matrix, verbosity: int = 1, matrices: int = 1, *args, **kwargs + ) -> Matrix | PartGen: + r"""Solves the least squares problem $\min || \mathrm{self} \, \mathbf{x} - \mathrm{rhs}||^2$. + + Uses SymPy's built-in method for least squares when the rank condition is met, otherwise uses a custom + solution approach using the normal equations: $\mathrm{self}^{\top} \mathrm{self} \, \mathbf{x} = \mathrm{self}^{\top} \mathrm{rhs}$ + + Args: + rhs (Matrix): The right-hand side matrix/vector `b` in `Ax = b`. + verbosity (int, optional): Level of verbosity (default is `1`): + + - 0: No output. + - 1: Display intermediate steps. + matrices (int, optional): + + - 1: Returns the least squares solution matrix. + - 2: Returns a [`PartGen`][(p).] with the part solution and general solution. + *args: Additional positional arguments passed to SymPy's + [`solve_least_squares`][sympy.matrices.matrixbase.MatrixBase.solve_least_squares] method. + **kwargs: Additional arguments passed to to SymPy's + [`solve_least_squares`][sympy.matrices.matrixbase.MatrixBase.solve_least_squares] method. + + Returns: + (Matrix): If `matrices=1`, returns the least squares solution matrix. + (PartGen): If `matrices=2`, returns a [`PartGen`][(p).] with the part solution and general solution. + + Examples: + >>> A = Matrix([[1, 0], [0, 0]]) + >>> b = Matrix([1, 2]) + >>> A.solve_least_squares(b, verbosity=0, matrices=1) + ... # in this case, least squares solution is not unique, + ... # so it returns a general solution. + Exception Encountered: Matrix must be non-singular. + Attempting custom solve... + Matrix([ + [1] + [y] + ]) + """ + + if verbosity == 0: + try: + A, b = sym.Matrix(self), sym.Matrix(rhs) + return A.solve_least_squares(rhs=b, *args, **kwargs) + except Exception as e: + print(f"Exception Encountered: {str(e)}") + print("Attempting custom solve...") + + ATA, ATb = self.T @ self, self.T @ rhs + if ATA.det() != 0 and verbosity >= 1: + print("self.T @ self is invertible. The least squares solution is unique.") + display( + "\\mathbf{x} = \\left(\\mathbf{A}^\\top \\mathbf{A}\\right)^{-1} \\mathbf{A}^\\top \\mathbf{b}", + opt="math", + ) + x = ATA.inv() @ ATb + display(x) + return x + + # Custom solve using sympy's solve method + sol = Matrix.create_unk_matrix(ATb.rows, 1) + sol_dicts = sym.solve(ATA @ sol - ATb, dict=True) + if not sol_dicts: + raise ValueError("Normal equations have no solution") + sol = sol.subs(sol_dicts[0]) + + if verbosity >= 1: + print("Before RREF: [self.T @ self | self.T @ rhs]") + aug_matrix = ATA.copy().row_join(ATb) + display(aug_matrix) + print("\nAfter RREF") + display(aug_matrix.rref()) + + if matrices == 1: + return sol + else: + return sol.sep_part_gen() + + @staticmethod + def create_vander( + num_rows: int = 1, num_cols: int = 1, symbol: str = "x", is_real: bool = True + ) -> Matrix: + """Creates a Vandermonde matrix with symbolic entries. + + This method generates a Vandermonde matrix of size `num_rows` x `num_cols` + where the entries are symbolic expressions. Each row in the matrix is formed + by raising a symbolic variable (indexed by row) to increasing powers (from 0 + to `num_cols-1`). The `is_real` flag determines whether the symbols are real-valued. + + Args: + num_rows (int, optional): The number of rows in the Vandermonde matrix. + num_cols (int, optional): The number of columns in the Vandermonde matrix. + symbol (str, optional): The base name for the symbols used in the matrix entries. + is_real (bool, optional): If True (default), the symbols are real-valued; + otherwise, they are complex. + + Returns: + (Matrix): A Vandermonde matrix with symbolic entries. + + Examples: + >>> Matrix.create_vander(2, 4, symbol='a') + Matrix([ + [1, a_1, a_1**2, a_1**3] + [1, a_2, a_2**2, a_2**3] + ]) + + See Also: + - [`apply_vander`][..] for applying the Vandermonde transformation to a matrix. + """ + + entries = sym.symbols(f"{symbol}_(1:{num_rows + 1})", is_real=is_real) + res = [] + for entry in entries: + sub_res = [] + for col_idx in range(num_cols): + # Raise the symbol to the power of the column index + sub_res.append(sym.Pow(entry, col_idx)) + res.append(sub_res) + return Matrix(res) + + def apply_vander(self, x: Matrix) -> Matrix: + """ + Applies a Vandermonde transformation to the current matrix using the given vector. + + This method applies a Vandermonde transformation to the current matrix by + substituting the free symbols in the last column with corresponding values + from the provided vector `x`. The number of rows in `self` must match the + number of elements in `x`, and `x` must be a column vector. + + Note: + - The matrix `self` is expected to be created via [`Matrix.create_vander()`][..create_vander]. + - The `x` vector provides the values to substitute in place of these symbols. + + Args: + x (Matrix): A column vector (Matrix object with a single column) containing + the values to substitute into the last column of the matrix. + + Returns: + (Matrix): A new Matrix object where the free symbols in the last column of + the original matrix are substituted by the corresponding values from `x`. + + Raises: + sympy.matrices.exceptions.ShapeError: If `x` is not a column vector or if + the number of rows in `self` does not match the size of `x`. + + Examples: + >>> mat = Matrix.create_vander(2, 2) + >>> x = Matrix([1, 2]) + >>> mat.apply_vander(x) + Matrix([ + [1, 1] + [1, 2] + ]) + """ + # Validate the size of the vector x + if x.cols != 1: + raise sym.ShapeError( + f"Input vector x must be a column vector. ({self.cols})" + ) + if self.rows != x.rows: + raise sym.ShapeError( + f"Number of rows in matrix ({self.rows}) must match the size of the input vector ({x.rows})" + ) + + # Get the free symbols from the last column of the matrix + ordered_syms = [next(iter(entry.free_symbols)) for entry in self.select_cols(-1)] # type: ignore + + # Create a substitution dictionary mapping symbols to values from vector x + substitution = {var: val for var, val in zip(ordered_syms, x)} # type: ignore + return self.subs(substitution) + + ############################# + # CHAPTER 6: EIGEN-ANALYSIS # + ############################# + + def cpoly(self, force_factor: bool = True) -> Mul | tuple[Mul, Mul]: + """Computes the characteristic polynomial of the matrix and attempts to factor it into real and complex parts. + + The characteristic polynomial is defined as `det(x * I - self)`, where `I` is the identity matrix of the same size as `self`. + + Args: + force_factor (bool): If `True`, the polynomial is fully factored, even if it doesn't have real factors. + If `False`, the polynomial is returned in its factored form if possible. + + Returns: + (Mul): If the polynomial factors only into real terms, returns a single factored polynomial. + (tuple[Mul, Mul]): If the polynomial has both real and complex factors, returns a tuple of two polynomials + one with real factors and the other with complex factors. + + Examples: + >>> mat = Matrix([[-1, 0], [0, 4]]) + >>> mat.cpoly() + (x - 4)*(x + 1) + """ + x = sym.symbols("x", real=True) + poly = (x * self.elem() - self).det() + if not force_factor: + return poly.factor() + # Attempt to factor poly into real factors + try: + roots = sym.roots( + poly + ) # TODO: FIX sym.roots NotImplementedError for multi variable + real_fact = [] + for root, mult in roots.items(): + term = x - root + if mult != 1: + term = sym.Pow(term, mult, evaluate=False) + if root.is_real: + real_fact.append(term) + poly /= term + + linear_fact = Mul(*real_fact, evaluate=False) + complex_fact = poly.expand().cancel().factor() + + if complex_fact == 1: + return linear_fact # type: ignore + else: + return linear_fact, complex_fact # type: ignore + except Exception as error: + print(f"Encountered Error: {error}") + return poly.factor() + + # Override + def is_diagonalizable( + self, reals_only: bool = True, verbosity: int = 1, *args, **kwargs + ) -> bool: + """Checks if the matrix is diagonalizable, with the option to focus only on real eigenvalues. + + A matrix is diagonalizable if it has enough linearly independent eigenvectors to form a basis for the space. + + Args: + reals_only (bool, optional): If True, diagonalization will focus on real eigenvalues. + verbosity (int, optional): Controls the level of output during the diagonalization process. + + - 0: No output. + - 1: Displays the characteristic polynomial, eigenvalues, algebraic multiplicities, and eigenspaces. + + *args: Additional positional arguments passed to SymPy's + [`is_diagonalizable`][sympy.matrices.matrixbase.MatrixBase.is_diagonalizable] method. + **kwargs: Additional arguments passed to SymPy's + [`is_diagonalizable`][sympy.matrices.matrixbase.MatrixBase.is_diagonalizable] method. + + Returns: + (bool): True if the matrix is diagonalizable, False otherwise. + + Examples: + >>> mat = Matrix([[1, 2, 0], [0, 3, 0], [2, -4, 2]]) + >>> mat.is_diagonalizable(reals_only=True, verbosity=0) + True + + See Also: + - SymPy's [`Matrix.is_diagonalizable`][sympy.matrices.matrixbase.MatrixBase.is_diagonalizable] + - Sympy's [`Matrix.eigenvects`][sympy.matrices.matrixbase.MatrixBase.eigenvects] for computing eigenvalues and their multiplicities. + - [`diagonalize`][..] for diagonalizing the matrix. + """ + + # Changed default for reals_only to True to align with MA1522 syllabus + if verbosity >= 1: + print("Characteristic Polynomial is: ") + display(self.cpoly()) + print("\nCheck if algebraic multiplicity equals number of eigenvectors.\n") + print("Eigenvectors are:") + for val, mult, space in self.eigenvects(): + if (val.is_real and reals_only) or not reals_only: + res = { + "eigenvalue": val, + "algebraic multiplicity": mult, + "eigenspace": Matrix.from_list(space), + } + display(res, opt="dict") + + return super().is_diagonalizable(reals_only, *args, **kwargs) + + def eigenvects_associated( + self, eigenvalue: Expr | int | float + ) -> list[Matrix] | None: + """Computes the eigenvectors associated with a given eigenvalue. + + This method finds all (nonzero) vectors `v` such that `(eigenvalue * I - self) * v = 0` + where `I` is the identity matrix of the same size as `self`. + + Args: + eigenvalue (Expr | int | float): The eigenvalue for which to compute the associated eigenvectors. + + Returns: + (list[Matrix]): A list of eigenvectors (as Matrix objects) associated with the given eigenvalue, + (None): If the eigenvalue does not correspond to any eigenvectors. + + Examples: + >>> mat = Matrix([[2, 0], [0, 3]]) + >>> mat.eigenvects_associated(2) + [Matrix([ + [1], + [0]])] + """ + return (eigenvalue * self.elem() - self).nullspace() + + # Override + def diagonalize( + self, reals_only: bool = True, verbosity: int = 0, *args, **kwargs + ) -> PDP: + """Diagonalizes the matrix if possible, focusing on real eigenvalues unless specified otherwise. + + Args: + reals_only (bool, optional): If `True`, diagonalization will focus on real eigenvalues. + verbosity (int, optional): Controls the level of output during the diagonalization process. + + - 0: No output. + - 1: Displays the characteristic polynomial and eigenvectors. + *args: Additional positional arguments passed to SymPy's + [`diagonalize`][sympy.matrices.matrixbase.MatrixBase.diagonalize] method. + **kwargs: Additional arguments passed to SymPy's + [`diagonalize`][sympy.matrices.matrixbase.MatrixBase.diagonalize] method. + + Returns: + (PDP): A dataclass containing: + + - `P` ([`Matrix`][...]): The matrix of eigenvectors. + - `D` ([`Matrix`][...]): The diagonal matrix of eigenvalues. + + Raises: + sympy.matrices.matrixbase.MatrixError: If the matrix is not diagonalizable. + + Examples: + >>> mat = Matrix([[1, 2], [3, 4]]) + >>> mat.diagonalize() + PDP(P=Matrix([ + [-sqrt(33)/6 - 1/2, -1/2 + sqrt(33)/6] + [ 1, 1] + ]), D=Matrix([ + [5/2 - sqrt(33)/2, 0] + [ 0, 5/2 + sqrt(33)/2] + ])) + """ + + # Changed default for reals_only to True to align with MA1522 syllabus + if verbosity >= 1: + print("Characteristic Polynomial") + poly = self.cpoly() + display(poly) + for root, _ in self.eigenvals().items(): + if reals_only and root.is_real is False: + continue + display( + _textify("Before RREF: ") + + sym.latex(root) + + r"\mathbb{I} - \mathrm{self}", + opt="math", + ) + expr = root * self.elem() - self + display(expr) + + print("\nAfter RREF:") + display(expr.rref()) + + print("\nEigenvectors:") + display(expr.nullspace()) + print("\n") + + try: + P, D = super().diagonalize(reals_only, *args, **kwargs) + except Exception: + eig_data = self.eigenvects() + cols_P: list[Matrix] = [] + diag_vals: list[Expr] = [] + for val, _mult, vecs in eig_data: + if reals_only and val.is_real is False: + continue + for v in vecs: + cols_P.append(v) + diag_vals.append(val) + if len(cols_P) != self.rows: + raise + P = Matrix(sym.Matrix.hstack(*cols_P)) + D = Matrix(sym.diag(*diag_vals)) + P.rm_aug_line() # Remove augmented line if exists + return PDP(P, D) + + def is_orthogonally_diagonalizable(self, verbosity: int = 2) -> bool: + """Determines whether the matrix is orthogonally diagonalizable. + + A matrix is orthogonally diagonalizable if and only if it is symmetric. + This method checks the symmetry of the matrix and optionally displays + diagnostic information based on the verbosity level. + + Args: + verbosity (int, optional): Level of diagnostic output. + + - 0: No output. + - 1: Displays the matrix. + - 2: Displays the result of the symmetry check. + + Returns: + (bool): True if the matrix is symmetric (orthogonally diagonalizable), False otherwise. + + Examples: + >>> mat = Matrix([[1, 2], [2, 1]]) + >>> mat.is_orthogonally_diagonalizable(verbosity=0) + True + + See Also: + - SymPy's [`Matrix.is_symmetric`][sympy.matrices.matrixbase.MatrixBase.is_symmetric] + + """ + if verbosity: + print(f"Check if matrix is symmetric: {self.is_symmetric()}") + if verbosity >= 2: + print("\nCheck if self == self^T:") + display(self == self.T) + return self.is_symmetric() + + # Override + def orthogonally_diagonalize( + self, reals_only: bool = True, factor: bool = True, verbosity=1, *args, **kwargs + ) -> PDP: + """Orthogonally diagonalizes the matrix, ensuring that eigenvectors corresponding to different eigenvalues are orthogonal. + + Args: + reals_only (bool): If True, only real eigenvalues are considered. + factor (bool): If True, the eigenvectors are orthogonalized using the Gram-Schmidt process. + verbosity (int): Controls the verbosity of output during the process. + *args: Additional positional arguments passed to the [`diagonalize`][..] method. + **kwargs: Additional arguments passed to the [`diagonalize`][..] method. + + Returns: + (PDP): A dataclass containing: + + - `P` ([`Matrix`][...]): The orthogonal matrix of eigenvectors. + - `D` ([`Matrix`][...]): The diagonal matrix of eigenvalues. + + Raises: + AssertionError: If the matrix is not orthogonally diagonalizable (i.e., not symmetric). + + Examples: + >>> mat = Matrix([[1, 2], [2, 1]]) + >>> mat.orthogonally_diagonalize(factor=False, verbosity=0) + PDP(P=Matrix([ + [-sqrt(2)/2, sqrt(2)/2] + [ sqrt(2)/2, sqrt(2)/2] + ]), D=Matrix([ + [-1, 0] + [ 0, 3] + ])) + + See Also: + - [`is_orthogonally_diagonalizable`][..] to check if the matrix is orthogonally diagonalizable. + - [`diagonalize`][..] for diagonalizing the matrix. + """ + + # Changed default for reals_only to True to align with MA1522 syllabus + # Note that you can just apply GSP on P directly, since eigenspace associated to different eigenvalues are orthogonal + # However, we follow the steps given in MA1522 syllabus here + assert self.is_orthogonally_diagonalizable(verbosity=verbosity) + # P, D = super().diagonalize(reals_only, *args, **kwargs) + P, D = self.diagonalize( + reals_only=reals_only, verbosity=verbosity, *args, **kwargs + ) + + d: DefaultDict[Expr, list[Matrix]] = defaultdict(list) + for i in range(P.cols): + d[D[i, i]].append(P.col(i)) + + result = [] + for val, vecs in d.items(): + if len(vecs) > 1: + # Require Gram Schmidt to ensure eigenvectors are orthogonal + if verbosity >= 1: + print("Eigenvalue: ", val) + print("[Gram Schmidt Process]") + if factor: + gram_result = Matrix.from_list(vecs).gram_schmidt( + factor=True, verbosity=verbosity + ) + if isinstance(gram_result, ScalarFactor): + result.append(gram_result.eval()) + elif isinstance(gram_result, Matrix): + result.append(gram_result) + else: + raise TypeError( + f"Unexpected return type from gram_schmidt: {type(gram_result)}" + ) + else: + result.append( + Matrix.from_list(vecs).gram_schmidt(factor, verbosity) + ) + else: + result.append(vecs[0].normalized()) + + if len(result) == 0: + ortho_P = P + else: + ortho_P = result[0] + for m in result[1:]: + ortho_P = ortho_P.row_join(m, aug_line=False) + + assert (ortho_P @ D @ ortho_P.T - self).norm().equals(0) is True + return PDP(ortho_P, D) + + def is_stochastic(self, verbosity: int = 1) -> bool: + """Checks if the matrix is stochastic. + + A matrix is stochastic if all its entries are non-negative and each column sums to 1. + This property is commonly used to identify transition matrices in Markov chains. + + Args: + verbosity (int, optional): Level of diagnostic output. + + - 0: No output. + - 1: Displays the result of the checks. + + Returns: + (bool): True if the matrix is stochastic, False otherwise. + + Examples: + >>> mat = Matrix([[0.5, 0.1], [0.5, 0.9]]) + >>> mat.simplify(rational=True) # Convert floats to symbolic fractions + >>> mat.is_stochastic(verbosity=0) + True + + >>> mat = Matrix([[1.1, 1], [-0.1, 0]]) + >>> mat.is_stochastic(verbosity=0) + False + """ + is_square = self.rows == self.cols + is_non_negative = all(entry >= 0 for entry in self.flat()) + is_prob_vectors = all(sum(self[:, i]) == 1 for i in range(self.cols)) # type: ignore + if verbosity >= 1: + print(f"Check if matrix is square: {is_square}") + print(f"Check if all entries are non-negative: {is_non_negative}") + print(f"Check if each column sums to 1: {is_prob_vectors}") + return is_square and is_non_negative and is_prob_vectors + + def equilibrium_vectors(self) -> Matrix: + """Computes the equilibrium vectors of the matrix, i.e., the nullspace of (I - A). + + Note: + - A matrix `P` has a unique equilibrium vector if it is stochastic and + there exists some positive integer `k` such that `P^k` only has positive entries. + + Returns: + (Matrix): A matrix containing equilibrium vectors normalized so that their + column sums to 1. + + Examples: + >>> mat = Matrix([[0.1, 0.9], [0.9, 0.1]]) + >>> mat.simplify() + >>> mat.equilibrium_vectors() + Matrix([ + [1/2] + [1/2] + ]) + """ + + P = Matrix.from_list((self.elem() - self).nullspace()) + for i in range(P.cols): + if sum(P[:, i]) != 0: # type: ignore + P[:, i] /= sum(P[:, i]) # type: ignore + return P + + def singular_value_decomposition( + self, verbosity: int = 0, tol: float = 0.0, verify: bool = True + ) -> SVD: + """Performs Singular Value Decomposition (SVD) on the matrix, following the MA1522 syllabus. + + Note: + - This function is known to take too much time and may kill Jupyter's kernel. Please use it with caution. + A workaround is to set `verify=False` to skip the verification step, or use the faster numerical SVD + method [`fast_svd`][..fast_svd] instead. + + Args: + verbosity (int, optional): Controls the verbosity of the output. + + - 0: No output. + - 1: Displays intermediate steps and results of the SVD process. + tol (float, optional): Tolerance for verification of the SVD result. + verify (bool): If `True`, verifies the result of the SVD by checking if `self = U @ S @ V.T`. + If `False`, skips the verification step for performance reasons. + + Returns: + (SVD): A dataclass containing: + + - `U` ([`Matrix`][...]): The left singular vectors. + - `S` ([`Matrix`][...]): The diagonal matrix of singular values. + - `V` ([`Matrix`][...]): The right singular vectors. + + Such that `self = U @ S @ V.T`. + + Examples: + >>> mat = Matrix([[3, 2, 2], [2, 3, -2]]) + >>> mat.singular_value_decomposition(verbosity=0, verify=False) + SVD(U=Matrix([ + [sqrt(2)/2, -sqrt(2)/2] + [sqrt(2)/2, sqrt(2)/2] + ]), S=Matrix([ + [5, 0, 0] + [0, 3, 0] + ]), V=Matrix([ + [sqrt(2)/2, -sqrt(2)/6, 2/3] + [sqrt(2)/2, sqrt(2)/6, -2/3] + [ 0, -2*sqrt(2)/3, -1/3] + ])) + + See Also: + - [`fast_svd`][..fast_svd] for a faster numerical SVD + - SymPy's [`Matrix.singular_value_decomposition`][sympy.matrices.matrixbase.MatrixBase.singular_value_decomposition] + """ + + if verbosity == 0 and not verify: + return self.fast_svd(option="sym", identify=False) + + if verbosity >= 1: + AT_A = self.T @ self + print("A^T A") + display(AT_A) + P, D = AT_A.orthogonally_diagonalize(verbosity=verbosity) + # Reverse index such that singular values are in decreasing order + sigma = [sym.sqrt(val) for val in D.diagonal()][::-1] + S = Matrix.diag(*[singular for singular in sigma if (singular != 0)]) + V = P.select_cols(*[i for i in range(P.cols)][::-1]) + + u_list = [] + for idx in range(1, S.rows + 1): + vec = V.col(idx - 1) + val = sigma[idx - 1] + if val != 0: + u_i = self @ vec / val + u_list.append(u_i) + display( + f"u_{idx} = (1/{sym.latex(val)})A{sym.latex(vec)} = {sym.latex(u_i)}", + opt="math", + ) + + U = Matrix.from_list(u_list) + # Extend basis using orthogonal complement and gram-schmidt if insufficient vectors + if U.cols < self.rows: + print("\nExtending U with its orthogonal complement.") + if U.cols == 0: + # Pad edge case with identity + orth = Matrix.eye(self.rows) + else: + complement = U.orthogonal_complement(verbosity=verbosity) + gram_result = complement.gram_schmidt( + factor=True, verbosity=verbosity + ) + if isinstance(gram_result, ScalarFactor): + orth = gram_result.full + else: + orth = gram_result + + orth = orth.normalized(factor=False) + assert isinstance(orth, Matrix), ( + f"Expected orth to be a Matrix, got {type(orth)}" + ) + U = U.row_join(orth, aug_line=False) + + # Add zero rows and columns to S so that matrix multiplication is defined + m, n = self.shape + r, c = S.shape + S = S.row_join(sym.zeros(r, n - c), aug_line=False).col_join( + sym.zeros(m - r, n) + ) + + if verify: + residual = (U @ S @ V.T - self).norm() + assert residual.evalf() <= tol + return SVD(U, S, V) + + m, n = self.shape + U, S, V = super().singular_value_decomposition() + # Reverse index such that singular values are in decreasing order + new_S = Matrix.diag(*S.diagonal()[::-1]) + + S_index = [i for i in range(S.cols)][::-1] + new_U = Matrix(U).select_cols(*S_index) + new_V = Matrix(V).select_cols(*S_index) + + # new_U = Matrix(U). + # Add orthonormal columns to U and V so that they are square matrices + new_U = new_U.QRdecomposition(full=True).Q + new_V = new_V.QRdecomposition(full=True).Q + + # Add zero rows and columns to S so that matrix multiplication is defined + r, c = new_S.shape + new_S = new_S.row_join(sym.zeros(r, n - c), aug_line=False).col_join( + sym.zeros(m - r, n) + ) + + if verify and (residues := (new_U @ new_S @ new_V.T - self).norm()) > tol: + res = residues.evalf() + warn( + f"Verification failed: norm of residual is {res} > {tol}", + RuntimeWarning, + stacklevel=2, + ) + return SVD(new_U, new_S, new_V) + + def fast_svd( + self, + option: Literal["np", "sym"] = "np", + identify: bool = True, + tol: float | None = None, + ) -> SVD | NumSVD: + """A faster version of SVD that computes numerically using NumPy's SVD function. + + This method is designed to be efficient and suitable for large matrices, but it does not guarantee + exact symbolic results like the [`singular_value_decomposition`][..singular_value_decomposition] method. + It uses [`numpy.linalg.svd`][numpy.linalg.svd] function to compute the singular value decomposition and + [`mpmath.identify`][mpmath.identify] function to identify rational numbers or surds if requested. + + Note: + - This method might not return exact values, even if identification is enabled as it does + not use SymPy's symbolic computation for SVD. + - Use this method when performance is a concern and exact symbolic results are not required. + + Args: + option (Literal["np", "sym"], optional): Whether to return numpy arrays or sympy matrices. + identify (bool, optional): Whether to attempt identification of rational numbers or surds. + If `True`, `option` must be `"sym"` to return symbolic matrices. + tol (float, optional): Tolerance for [`mpmath.identify`][mpmath.identify] function as well as + for verifying the SVD result. + + Returns: + (SVD): A dataclass containing: + + - `U` ([`Matrix`][...]): The left singular vectors. + - `S` ([`Matrix`][...]): The diagonal matrix of singular values. + - `V` ([`Matrix`][...]): The right singular vectors. + + (NumSVD): A named tuple containing: + + - `U` ([`numpy.ndarray`][numpy.ndarray]): The left singular vectors as a NumPy array. + - `S` ([`numpy.ndarray`][numpy.ndarray]): The diagonal matrix of singular values as a NumPy array. + - `V` ([`numpy.ndarray`][numpy.ndarray]): The right singular vectors as a NumPy array. + + Examples: + >>> mat = Matrix([[3, 2, 2], [2, 3, -2]]) + >>> mat.fast_svd(option="sym", identify=False) # doctest: +SKIP + SVD(U=Matrix([ + [-0.707106781186548, -0.707106781186548] + [-0.707106781186547, 0.707106781186548] + ]), S=Matrix([ + [5.0, 0.0, 0.0] + [0.0, 3.0, 0.0] + ]), V=Matrix([ + [ -0.707106781186548, -0.235702260395516, -0.666666666666667] + [ -0.707106781186548, 0.235702260395516, 0.666666666666667] + [-6.47932334256779e-17, -0.942809041582063, 0.333333333333333] + ])) + + See Also: + - [`singular_value_decomposition`][..singular_value_decomposition] for the + symbolic version of SVD. + - NumPy's [`numpy.linalg.svd`][numpy.linalg.svd] for the underlying numerical + SVD implementation. + """ + + m, n = self.shape + U, S, Vh = np.linalg.svd(np.array(self, dtype=np.float64)) + # To align with MA1522 Syllabus, return V instead of V.T + # Need not use conjugate transpose as MA1522 deals with real matrices + V = Vh.T + + # Create sigma matrix from singular values + S = np.diag(S) + r, c = S.shape + S = np.concat((S, np.zeros((r, n - c))), axis=1) + S = np.concat((S, np.zeros((m - r, n))), axis=0) + if option == "np": + return NumSVD(U, S, V) + elif option == "sym": + U, S, V = Matrix(U), Matrix(S), Matrix(V) + if identify: + U = U.identify(tol=tol, suppress_warnings=True) + S = S.identify(tol=tol, suppress_warnings=True) + V = V.identify(tol=tol, suppress_warnings=True) + residues = (self - U @ S @ V.T).norm() + _tol = tol if tol is not None else 0.0 + if residues > _tol: + res = residues.evalf() + warn( + f"Non-zero Identification Error: {res}", + RuntimeWarning, + stacklevel=2, + ) + return SVD(U, S, V) + else: + return SVD(U, S, V) + else: + warn( + f"Invalid option '{option}'. Expected 'np' or 'sym'. Returning NumSVD.", + SyntaxWarning, + stacklevel=2, + ) + return NumSVD(U, S, V) + + #################################### + # CHAPTER 7: LINEAR TRANSFORMATION # + #################################### + + def standard_matrix( + self, out: Matrix, matrices: int = 1 + ) -> list[Matrix] | list[PartGen]: + """Returns the standard matrix for the transformation from self to out. + + The standard matrix is a matrix `T` such that `T @ self = out`, where `self` is the matrix + whos columns represent the input vectors and `out` is the matrix whose columns represent the output vectors. + + Note: + - The standard matrix may not be unique if the transformation is not injective. + - If multiple solutions are found, the first solution is returned. + + Args: + out (Matrix): The target matrix for the transformation. + matrices (int): The type of matrices to return: + + - 1: Returns the standard matrix. + - 2: Returns a [`PartGen`][(p).] with the part solution and general solution. + + Returns: + (list[Matrix]): If `matrices=1`, returns the standard matrix for the transformation. + (list[PartGen]): If `matrices=2`, returns a [`PartGen`][(p).] with the part solution and general solution. + + Examples: + >>> input = Matrix([[1, 0, 1], [2, -1, 0], [0, 3, 1]]) + >>> output = Matrix([[4, 2, 3], [5, -1, 0], [1, 4, 2]]) + >>> input.standard_matrix(output) + [Matrix([ + [ 2, 1, 1] + [-3/5, 14/5, 3/5] + [ 3/5, 1/5, 7/5] + ])] + """ + X = Matrix.create_unk_matrix(r=out.rows, c=self.rows) + equal_0 = X @ self - out + if len(self.free_symbols) != 0: + # Add zeroing examples to condition the transformation matrix for unknown vectors + examples = [] + for symbol in (symbols := self.free_symbols): + sub = {s: 0 for s in symbols if s != symbol} + sub[symbol] = 1 + examples.append(equal_0.subs(sub)) + + condition = Matrix.from_list(examples) + equal_0 = equal_0.row_join(condition, aug_line=False) + + sol = sym.solve(equal_0, X.free_symbols, dict=True) + if len(sol) == 0: + raise ValueError( + "No solution found for the standard matrix. " + "This may indicate that the transformation is not linear." + ) + res = [] + for s in sol: + tmp = X.subs(s) + if matrices == 1: + res.append(tmp) + elif matrices == 2: + res.append(tmp.sep_part_gen()) + else: + raise ValueError( + f"Invalid value for matrices: {matrices}. Expected 1 or 2." + ) + return res From e25f6c2461be37045b1929ed7860d928f53fdcce Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Mon, 11 May 2026 19:57:46 +0800 Subject: [PATCH 10/11] Update test cases to detect future regressions --- tests/chapters/test_c2_matrix_algebra.py | 7 +++++++ .../chapters/test_c5_orthogonality_and_lss.py | 15 ++++++++++++++ tests/test_basic_manipulators.py | 13 ++++++++++++ tests/test_decompositions.py | 20 ++++++++++++------- tests/tutorials/test_tut04.py | 4 ++++ 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/tests/chapters/test_c2_matrix_algebra.py b/tests/chapters/test_c2_matrix_algebra.py index ab40390..c956492 100644 --- a/tests/chapters/test_c2_matrix_algebra.py +++ b/tests/chapters/test_c2_matrix_algebra.py @@ -27,6 +27,13 @@ def test_left_inverse(self): assert inv is not None assert (inv @ mat) == Matrix.eye(2) + def test_auto_left_inverse_for_tall_full_column_rank(self): + """Automatic inverse detection should choose a left inverse.""" + mat = Matrix([[1, 0], [0, 1], [1, 1]]) + inv = mat.inverse() + assert inv is not None + assert (inv @ mat) == Matrix.eye(2) + def test_right_inverse(self): """Test right inverse for full row rank matrix""" mat = Matrix([[1, 0, 1], [0, 1, 1]]) diff --git a/tests/chapters/test_c5_orthogonality_and_lss.py b/tests/chapters/test_c5_orthogonality_and_lss.py index 35be1d7..55c53a0 100644 --- a/tests/chapters/test_c5_orthogonality_and_lss.py +++ b/tests/chapters/test_c5_orthogonality_and_lss.py @@ -70,6 +70,14 @@ def test_gram_schmidt(self): assert ortho_mat.full.is_vec_orthogonal() is True # type: ignore assert ortho_mat.full.select_cols(0).dot(ortho_mat.full.select_cols(1)) == 0 # type: ignore + def test_gram_schmidt_complex_uses_hermitian_projection(self): + mat = Matrix([[1, sym.I], [sym.I, 0]]) + ortho_mat = mat.gram_schmidt(factor=False, verbosity=0) + assert isinstance(ortho_mat, Matrix) + assert ortho_mat.select_cols(0).dot( + ortho_mat.select_cols(1), hermitian=True + ) == 0 + def test_QRdecomposition(self): mat = Matrix([[1, 1], [1, 0]]) q, r = mat.QRdecomposition() @@ -78,6 +86,13 @@ def test_QRdecomposition(self): # Verify R is upper triangular assert r[1, 0] == 0 + def test_full_QRdecomposition_rectangular(self): + mat = Matrix([[1, 0], [0, 1], [1, 1]]) + q, r = mat.QRdecomposition(full=True) + assert q.shape == (3, 3) + assert r.shape == (3, 2) + assert (q @ r).equals(mat) + def test_solve_least_squares(self): """Test least squares solution""" A = Matrix([[0, 1], [1, 1], [2, 1]]) diff --git a/tests/test_basic_manipulators.py b/tests/test_basic_manipulators.py index 0a7e944..c79f5df 100644 --- a/tests/test_basic_manipulators.py +++ b/tests/test_basic_manipulators.py @@ -77,6 +77,13 @@ def test_sep_part_gen(self): assert part_gen.part_sol == Matrix([[1, 0], [2, 0]]) assert part_gen.gen_sol == Matrix([[x, y], [0, x + y]]) + def test_sep_unk_splits_each_symbol(self): + x, y = sym.symbols("x y") + mat = Matrix([[x + y, 2 * x]]) + parts = mat.sep_unk() + assert parts[x] == Matrix([[1, 2]]) + assert parts[y] == Matrix([[1, 0]]) + def test_scalar_factor(self): mat = Matrix([[2, 4], [6, 8]]) scalar_factor = mat.scalar_factor() @@ -84,6 +91,12 @@ def test_scalar_factor(self): assert scalar_factor.full == Matrix([[1, 1], [3, 2]]) assert scalar_factor.diag == Matrix([[2, 0], [0, 4]]) + def test_scalar_factor_symbolic_reconstructs(self): + x = sym.symbols("x") + mat = Matrix([[2 * x, 4], [6 * x, 8]]) + scalar_factor = mat.scalar_factor() + assert scalar_factor.full @ scalar_factor.diag == mat + def test_hermitian_transpose(self): """Verify the .H property for complex matrices.""" A = Matrix([[1, 2 + sym.I], [3 - sym.I, 4]]) diff --git a/tests/test_decompositions.py b/tests/test_decompositions.py index d08c8b7..f6a1be9 100644 --- a/tests/test_decompositions.py +++ b/tests/test_decompositions.py @@ -2,12 +2,11 @@ import sympy as sym from ma1522 import Matrix, SVD, PDP -pytest.skip("Skipping this entire file because it's under construction", allow_module_level=True) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def _reconstruction_norm(svd: SVD, A: Matrix) -> float: """Numerical Frobenius norm of U S V^T - A.""" U = svd.U.evalf() @@ -16,6 +15,7 @@ def _reconstruction_norm(svd: SVD, A: Matrix) -> float: diff = U @ S @ V.T - A.evalf() return float(sym.re(diff.norm())) + def _diag_reconstruction_norm_no_inv(pdp: PDP, A: Matrix) -> float: """Verify A = P D P^{-1} without inverting P: check ||A P - P D|| numerically.""" P = pdp.P.evalf() @@ -24,13 +24,15 @@ def _diag_reconstruction_norm_no_inv(pdp: PDP, A: Matrix) -> float: diff = A_num @ P - P @ D return float(sym.re(diff.norm())) + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- + class TestSVDDecomposition: """Regression and edge-case tests for Singular Value Decomposition.""" - + def test_4x3_irrational_reconstruction(self): """Test SVD on matrix with irrational eigenvalues (Regression).""" A = Matrix([[1, -2, -1], [2, 0, 1], [2, -4, 2], [4, 0, 0]]) @@ -47,9 +49,11 @@ def test_rank_deficient_reconstruction(self): def test_fast_svd_tol_none(self): """Verify fast_svd handles tol=None correctly (Regression).""" mat = Matrix([[1.1, 1], [-0.1, 0]]) - svd = mat.fast_svd(option="sym", identify=True, tol=None) + with pytest.warns(RuntimeWarning, match="Non-zero Identification Error"): + svd = mat.fast_svd(option="sym", identify=True, tol=None) assert isinstance(svd, SVD) + class TestDiagonalization: """Regression tests for Matrix diagonalization.""" @@ -60,6 +64,7 @@ def test_ata_irrational_diagonalization(self): pdp = ATA.diagonalize(verbosity=0) assert _diag_reconstruction_norm_no_inv(pdp, ATA) < 1e-8 + class TestVectorSpaces: """Tests for Vector Space operations (Gram-Schmidt, Transition Matrix).""" @@ -76,14 +81,15 @@ def test_gram_schmidt_orthonormal(self): v2 = Matrix([1, 0, 0]) res = Matrix.from_list([v1, v2]).gram_schmidt(factor=False, verbosity=0) assert res.col(0) == v1.normalized() - assert res.col(1) == (v2 - (v2.dot(v1)/v1.dot(v1))*v1).normalized() + assert res.col(1) == (v2 - (v2.dot(v1) / v1.dot(v1)) * v1).normalized() + class TestNegativeDecompositions: """Negative tests for decompositions (invalid inputs).""" - + def test_diagonalize_non_diagonalizable(self): """Verify diagonalize raises error for non-diagonalizable matrices.""" # Shear matrix [[1, 1], [0, 1]] is not diagonalizable A = Matrix([[1, 1], [0, 1]]) - with pytest.raises(Exception): + with pytest.raises(Exception): A.diagonalize(verbosity=0) diff --git a/tests/tutorials/test_tut04.py b/tests/tutorials/test_tut04.py index 925e6a5..3453b4f 100644 --- a/tests/tutorials/test_tut04.py +++ b/tests/tutorials/test_tut04.py @@ -4,6 +4,10 @@ class TestTutorial04: + def test_zero_subspace_is_subspace_of_zero_subspace(self): + zero_subspace = Matrix.zeros(2, 0) + assert zero_subspace.is_subspace_of(zero_subspace, verbosity=0) + def test_question_2a(self): U = Matrix.from_str("2 1 0 3; 3 -1 5 2; -1 0 2 1").T actual_constraints = U.column_constraints() From d0655dd7146f2acdb3aacf7871bf2f386fc6e1ed Mon Sep 17 00:00:00 2001 From: ys_teng <58208381+YeeShin504@users.noreply.github.com> Date: Mon, 11 May 2026 20:19:03 +0800 Subject: [PATCH 11/11] Add more tests to improve coverage --- .../chapters/test_c5_orthogonality_and_lss.py | 14 ++++- tests/chapters/test_c6_eigen_analysis.py | 28 +++++++++- .../chapters/test_c7_linear_transformation.py | 31 ++++++++++- tests/test_basic_manipulators.py | 21 +++++++ tests/test_factory_methods.py | 55 ++++++++++++++++++- tests/test_utils.py | 16 +++++- 6 files changed, 159 insertions(+), 6 deletions(-) diff --git a/tests/chapters/test_c5_orthogonality_and_lss.py b/tests/chapters/test_c5_orthogonality_and_lss.py index 55c53a0..7c15c80 100644 --- a/tests/chapters/test_c5_orthogonality_and_lss.py +++ b/tests/chapters/test_c5_orthogonality_and_lss.py @@ -14,7 +14,7 @@ import sympy as sym -from ma1522 import Matrix, VecDecomp +from ma1522 import Matrix, PartGen, VecDecomp class TestChapter5: @@ -86,6 +86,11 @@ def test_QRdecomposition(self): # Verify R is upper triangular assert r[1, 0] == 0 + def test_QRdecomposition_verbose(self): + mat = Matrix([[1, 1], [1, 0]]) + q, r = mat.QRdecomposition(verbosity=1) + assert (q @ r).equals(mat) + def test_full_QRdecomposition_rectangular(self): mat = Matrix([[1, 0], [0, 1], [1, 1]]) q, r = mat.QRdecomposition(full=True) @@ -99,3 +104,10 @@ def test_solve_least_squares(self): b = Matrix([[6], [0], [0]]) x = A.solve_least_squares(b) assert (A @ x - b).norm() == sym.sqrt(6) # Minimized error + + def test_solve_least_squares_fallback_part_gen(self): + A = Matrix([[1, 0], [0, 0]]) + b = Matrix([[1], [2]]) + result = A.solve_least_squares(b, verbosity=0, matrices=2) + assert isinstance(result, PartGen) + assert A @ result.part_sol == Matrix([[1], [0]]) diff --git a/tests/chapters/test_c6_eigen_analysis.py b/tests/chapters/test_c6_eigen_analysis.py index e650975..875c2e1 100644 --- a/tests/chapters/test_c6_eigen_analysis.py +++ b/tests/chapters/test_c6_eigen_analysis.py @@ -12,8 +12,9 @@ """ import sympy as sym +import pytest -from ma1522 import Matrix +from ma1522 import Matrix, SVD class TestChapter6: @@ -41,6 +42,15 @@ def test_diagonalize(self): pdp = mat.diagonalize() assert (pdp.P @ pdp.D @ pdp.P.inv() - mat).norm() < 1e-10 + def test_diagonalize_verbose_filters_complex_roots(self): + mat = Matrix([[0, -1], [1, 0]]) + with pytest.raises(Exception): + mat.diagonalize(reals_only=True, verbosity=1) + + def test_eigenvects_associated(self): + mat = Matrix([[2, 0], [0, 3]]) + assert mat.eigenvects_associated(2) == [Matrix([[1], [0]])] + def test_is_orthogonally_diagonalizable(self): mat = Matrix([[1, 2], [2, 1]]) assert mat.is_orthogonally_diagonalizable() is True @@ -52,6 +62,16 @@ def test_orthogonally_diagonalize(self): pdp = mat.orthogonally_diagonalize(verbosity=0) assert (pdp.P @ pdp.D @ pdp.P.T - mat).norm() < 1e-10 + def test_orthogonally_diagonalize_without_factoring(self): + mat = Matrix.eye(2) + pdp = mat.orthogonally_diagonalize(factor=False, verbosity=0) + assert (pdp.P @ pdp.D @ pdp.P.T - mat).norm() < 1e-10 + + def test_is_stochastic(self): + assert Matrix([[sym.Rational(1, 2), sym.Rational(1, 3)], [sym.Rational(1, 2), sym.Rational(2, 3)]]).is_stochastic(verbosity=0) + assert not Matrix([[1, 1], [-1, 0]]).is_stochastic(verbosity=0) + assert not Matrix([[1], [0]]).is_stochastic(verbosity=0) + def test_equilibrium_vectors(self): mat = Matrix([[0.8, 0.3], [0.2, 0.7]]) eq_vects = mat.equilibrium_vectors() @@ -63,6 +83,12 @@ def test_fast_svd(self): U, S, V = svd.U, svd.S, svd.V assert (Matrix(U) @ Matrix(S) @ Matrix(V).T - mat).norm() < 1e-10 + def test_fast_svd_invalid_option_falls_back_to_numpy_result(self): + mat = Matrix([[1, 2], [3, 4]]) + with pytest.warns(SyntaxWarning, match="Invalid option"): + svd = mat.fast_svd(option="bad") # type: ignore[arg-type] + assert not isinstance(svd, SVD) + def test_singular_value_decomposition(self): mat = Matrix([[1, 2], [3, 4]]) svd = mat.singular_value_decomposition(verbosity=2) diff --git a/tests/chapters/test_c7_linear_transformation.py b/tests/chapters/test_c7_linear_transformation.py index 04452c4..752ca4b 100644 --- a/tests/chapters/test_c7_linear_transformation.py +++ b/tests/chapters/test_c7_linear_transformation.py @@ -2,7 +2,10 @@ - standard_matrix """ -from ma1522 import Matrix +import pytest +import sympy as sym + +from ma1522 import Matrix, PartGen class TestLinearTransformations: @@ -13,3 +16,29 @@ def test_standard_matrix(self): output_vectors = standard_matrix @ input_vectors sol = Matrix.standard_matrix(input_vectors, output_vectors)[0] assert sol == standard_matrix + + def test_standard_matrix_with_symbolic_input_vectors(self): + x = sym.symbols("x") + input_vectors = Matrix([[x, 0], [0, 1]]) + output_vectors = Matrix([[2 * x, 0], [0, 3]]) + + sol = input_vectors.standard_matrix(output_vectors)[0] + + assert sol == Matrix([[2, 0], [0, 3]]) + + def test_standard_matrix_returns_part_gen(self): + input_vectors = Matrix([[1], [0]]) + output_vectors = Matrix([[2], [0]]) + + sol = input_vectors.standard_matrix(output_vectors, matrices=2)[0] + + assert isinstance(sol, PartGen) + assert sol.part_sol @ input_vectors == output_vectors + + def test_standard_matrix_raises_when_no_solution_exists(self): + with pytest.raises(ValueError, match="No solution found"): + Matrix.zeros(2, 1).standard_matrix(Matrix.ones(2, 1)) + + def test_standard_matrix_rejects_invalid_return_mode(self): + with pytest.raises(ValueError, match="Invalid value for matrices"): + Matrix.eye(2).standard_matrix(Matrix.eye(2), matrices=3) diff --git a/tests/test_basic_manipulators.py b/tests/test_basic_manipulators.py index c79f5df..246dcff 100644 --- a/tests/test_basic_manipulators.py +++ b/tests/test_basic_manipulators.py @@ -97,6 +97,27 @@ def test_scalar_factor_symbolic_reconstructs(self): scalar_factor = mat.scalar_factor() assert scalar_factor.full @ scalar_factor.diag == mat + def test_scalar_factor_by_rows(self): + mat = Matrix([[2, 4], [6, 8]]) + scalar_factor = mat.scalar_factor(column=False) + assert scalar_factor.order == "DF" + assert scalar_factor.diag @ scalar_factor.full == mat + + def test_symbolic_reduce_row_updates_l_matrix(self): + x = sym.symbols("x") + mat = Matrix([[1, 0], [x, 1]]) + l_matrix = Matrix.eye(2) + + mat._symbolic_reduce_row(1, 0, 0, L_matrix=l_matrix) + + assert mat == Matrix([[1, 0], [0, 1]]) + assert l_matrix == Matrix([[1, 0], [x, 1]]) + + def test_symbolic_reduce_row_warns_when_max_tries_exceeded(self): + mat = Matrix([[0], [1]]) + with pytest.warns(RuntimeWarning, match="Max tries exceeded"): + mat._symbolic_reduce_row(1, 0, 0, max_tries=0) + def test_hermitian_transpose(self): """Verify the .H property for complex matrices.""" A = Matrix([[1, 2 + sym.I], [3 - sym.I, 4]]) diff --git a/tests/test_factory_methods.py b/tests/test_factory_methods.py index 6b7dd1e..69bd3d5 100644 --- a/tests/test_factory_methods.py +++ b/tests/test_factory_methods.py @@ -234,6 +234,38 @@ def test_invalid_vectors(self, vectors): with pytest.raises((ValueError, IndexError)): Matrix.from_list(vectors) + def test_empty_vectors_returns_empty_matrix(self): + assert Matrix.from_list([]) == Matrix([]) + + def test_augmented_position_is_preserved(self): + result = Matrix.from_list([Matrix([1, 2]), Matrix([3, 4])], aug_pos=0) + assert result == Matrix([[1, 3], [2, 4]], aug_pos=0) + assert "|" in repr(result) + + +class TestShape: + def test_diagonal_shape_pads_rectangular_matrix(self): + wide = Matrix([[1, 2, 3], [4, 5, 6]]) + tall = Matrix([[1, 2], [3, 4], [5, 6]]) + + assert wide._shape(Shape.DIAGONAL) == Matrix([[1, 0, 0], [0, 5, 0]]) + assert tall._shape(Shape.DIAGONAL) == Matrix([[1, 0], [0, 4], [0, 0]]) + + def test_scalar_shape_rejects_non_square_matrix(self): + with pytest.raises(sym.NonSquareMatrixError): + Matrix([[1, 2, 3], [4, 5, 6]])._shape(Shape.SCALAR) + + def test_strict_and_symmetric_shapes(self): + mat = Matrix([[1, 2], [3, 4]]) + + assert mat._shape(Shape.STRICT_UPPER) == Matrix([[0, 2], [0, 0]]) + assert mat._shape(Shape.STRICT_LOWER) == Matrix([[0, 0], [3, 0]]) + assert mat._shape(Shape.SYMMETRIC) == Matrix([[1, 2], [2, 4]]) + + def test_symmetric_shape_rejects_non_square_matrix(self): + with pytest.raises(sym.NonSquareMatrixError): + Matrix([[1, 2, 3], [4, 5, 6]])._shape(Shape.SYMMETRIC) + class TestCreateUnkMatrix: """Tests for Matrix.create_unk_matrix() factory method @@ -299,12 +331,25 @@ def test_create_rand_matrix(self): assert mat.shape == (2, 2) assert mat == Matrix([[81, 14], [3, 94]]) + def test_create_rand_matrix_with_shape(self): + mat = Matrix.create_rand_matrix(2, 2, shape=Shape.STRICT_UPPER, seed=42) + assert mat == Matrix([[0, 14], [0, 0]]) + class TestApplyVander: """Regression tests for Vandermonde matrix applications.""" + def test_create_vander(self): + result = Matrix.create_vander(2, 4) + assert result.shape == (2, 4) + assert [[str(entry) for entry in row] for row in result.tolist()] == [ + ["1", "x_1", "x_1**2", "x_1**3"], + ["1", "x_2", "x_2**2", "x_2**3"], + ] + def test_basic_substitution(self): V = Matrix.create_vander(3, 3) x_vec = Matrix([[2], [3], [5]]) result = V.apply_vander(x_vec) + assert result == Matrix([[1, 2, 4], [1, 3, 9], [1, 5, 25]]) assert result.free_symbols == set() def test_free_symbols_not_mutated(self): @@ -314,6 +359,14 @@ def test_free_symbols_not_mutated(self): V.apply_vander(x_vec) assert frozenset(V.free_symbols) == syms_before + def test_apply_vander_rejects_non_column_vector(self): + with pytest.raises(sym.ShapeError): + Matrix.create_vander(2, 2).apply_vander(Matrix([[1, 2]])) + + def test_apply_vander_rejects_row_mismatch(self): + with pytest.raises(sym.ShapeError): + Matrix.create_vander(2, 2).apply_vander(Matrix([[1], [2], [3]])) + class TestOverriddenFactoryMethods: def test_eye(self): @@ -344,4 +397,4 @@ def test_T_property(self): def test_H_property(self): mat = Matrix([[1, 2*sym.I], [3+4*sym.I, 4]]) assert mat.H == Matrix([[1, 3-4*sym.I], [-2*sym.I, 4]]) - assert isinstance(mat.H, Matrix) \ No newline at end of file + assert isinstance(mat.H, Matrix) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8865b75..d2e7708 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import sympy as sym +from unittest.mock import patch from ma1522.utils import _powerset, _is_zero @@ -35,7 +36,7 @@ def test_latex_wrapping(self): def test_ipython_detection(self): """Mock various environments to verify IPython detection logic.""" from ma1522.utils import _is_IPython - from unittest.mock import patch, MagicMock + from unittest.mock import MagicMock # Standard Python (get_ipython doesn't exist) with patch("IPython.core.getipython.get_ipython", side_effect=NameError): @@ -62,9 +63,20 @@ def test_ipython_detection(self): mock_get.return_value = mock_shell assert _is_IPython() is False + def test_ipython_detection_without_ipython_installed(self): + from ma1522.utils import _is_IPython + + def fake_import(name, *args, **kwargs): + if name == "IPython.core.getipython": + raise ImportError + return original_import(name, *args, **kwargs) + + original_import = __import__ + with patch("builtins.__import__", side_effect=fake_import): + assert _is_IPython() is False + def test_display_ipython_and_fallback(self): from ma1522.utils import display - from unittest.mock import patch, MagicMock # IPython SUCCESS with patch("ma1522.utils._is_IPython", return_value=True):